Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ Versions follow the `version+build` scheme from `pubspec.yaml`, bumped via

### Fixed

- The file-attachment button no longer disappears on a freshly created thread.
A new thread's attachment support cannot be read from its history until its
first reply arrives, and that undetermined state was being treated as
"unsupported"; the composer now keeps the room's attachment capability in
that window instead of hiding the button.
- Citations now appear for agents whose sources arrive under a non-`rag` state
namespace (such as the analysis agent). Citation extraction reads every
citation-bearing namespace in the agent state rather than only `rag`, so a
Expand Down
41 changes: 27 additions & 14 deletions lib/src/modules/room/ui/room_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,12 @@ class _RoomScreenState extends State<RoomScreen> {

late DocumentFilterHydrator _filterHydrator;

/// Per-thread attachment support hydrated from thread history (the sandbox
/// namespace in the thread's AG-UI state). A missing entry means history has
/// not loaded, or was bypassed for a registry-restored / just-spawned thread;
/// callers fall back to the room's current capability in that case.
/// Per-thread attachment support resolved from thread history (the sandbox
/// namespace in the thread's AG-UI state). A missing entry means it could not
/// be resolved — history has not loaded, was bypassed for a registry-restored
/// / just-spawned thread, or the thread has no finished run yet (the backend
/// omits `run_input` for unfinished runs, so history alone cannot tell).
/// Callers fall back to the room's current capability in that case.
final Map<String, bool> _threadAttachmentSupport = <String, bool>{};

/// Show the filter button only when filtering is enabled and there is
Expand Down Expand Up @@ -852,15 +854,30 @@ class _RoomScreenState extends State<RoomScreen> {
void _onThreadHistoryLoaded(String threadId, ThreadHistory history) {
if (!mounted) return;
if (threadId != widget.threadId) return; // stale fetch for another thread
if (_threadAttachmentSupport[threadId] != history.supportsAttachments) {
// Only record a resolved value. When history couldn't resolve state (null,
// e.g. an unfinished-only thread), leave the entry unset so the attach gate
// falls back to the room capability instead of a false negative.
final resolved = history.supportsAttachments;
if (resolved != null && _threadAttachmentSupport[threadId] != resolved) {
setState(() {
_threadAttachmentSupport[threadId] = history.supportsAttachments;
_threadAttachmentSupport[threadId] = resolved;
});
}
if (!_filterEnabled) return;
_filterHydrator.setFilter(threadId, history.documentFilter);
}

/// Whether attachments are enabled for [threadId] within [room].
///
/// Prefers the thread's resolved capability; when it could not be resolved
/// (no entry — see [_threadAttachmentSupport]), falls back to the room-level
/// capability. An undetermined thread must not collapse to `false` here, as
/// that would hide attachments on a thread whose state simply hasn't
/// resolved yet.
bool _attachEnabledForThread(String threadId, Room? room) =>
_threadAttachmentSupport[threadId] ??
(room?.supportsAttachments ?? false);

/// Seeds the resolved selection — but only if the thread is still active and
/// the user hasn't already made a selection for it during the async load
/// (local edit wins).
Expand Down Expand Up @@ -1576,9 +1593,8 @@ class _RoomScreenState extends State<RoomScreen> {
? _state.uploadTracker.roomUploads(widget.roomId).watch(context)
: const UploadsLoaded(<DisplayUpload>[]);
final threadId = threadView?.threadId;
final threadAttachEnabled = threadId != null
? (_threadAttachmentSupport[threadId] ?? roomAttachEnabled)
: false;
final threadAttachEnabled =
threadId != null && _attachEnabledForThread(threadId, room);
final UploadsStatus threadStatus = threadAttachEnabled
? _state.uploadTracker
.threadUploads(widget.roomId, threadId)
Expand Down Expand Up @@ -1996,8 +2012,7 @@ class _RoomScreenState extends State<RoomScreen> {
final streaming = threadView.streamingState.watch(context);
final sendError = threadView.lastSendError.watch(context);
final reconnectStatus = threadView.reconnectStatus.watch(context);
final attachEnabled = _threadAttachmentSupport[threadView.threadId] ??
(room?.supportsAttachments ?? false);
final attachEnabled = _attachEnabledForThread(threadView.threadId, room);

_restoreUnsentText(sendError?.unsentText);

Expand Down Expand Up @@ -2112,9 +2127,7 @@ class _RoomScreenState extends State<RoomScreen> {
) {
final threadId = threadView?.threadId;
final attachEnabled = threadId != null
? (_threadAttachmentSupport[threadId] ??
room?.supportsAttachments ??
false)
? _attachEnabledForThread(threadId, room)
: (room?.supportsAttachments ?? false);
VoidCallback? attachCallback(Future<PickFilesResult?> Function() pick) {
if (!attachEnabled) return null;
Expand Down
12 changes: 10 additions & 2 deletions packages/soliplex_client/lib/src/domain/thread_history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,17 @@ class ThreadHistory {
/// (not any state event) is the only record of the thread's active filter.
final String? documentFilter;

/// Whether this thread's AG-UI state carries the [sandboxSkillName]
/// Whether this thread's resolved AG-UI state carries the [sandboxSkillName]
/// namespace, i.e. attachments are available for this thread.
bool get supportsAttachments => aguiState.containsKey(sandboxSkillName);
///
/// `null` when history carried no AG-UI state to resolve (empty state) —
/// chiefly a freshly created thread whose only run is unfinished: it has no
/// replayable events, and the backend omits `run_input` for unfinished runs
/// on the history endpoint, so history alone cannot tell. Callers must fall
/// back to the room-level capability rather than treating this as a
/// definitive `false`.
bool? get supportsAttachments =>
aguiState.isEmpty ? null : aguiState.containsKey(sandboxSkillName);
}

/// Decoded AG-UI events for a single run, in arrival order.
Expand Down
44 changes: 44 additions & 0 deletions packages/soliplex_client/test/api/soliplex_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,50 @@ void main() {
});
});

group('getThreadHistory', () {
test(
'reports undetermined attachment support for a freshly created thread '
'(unfinished run, no run_input on GET)',
() async {
// Real backend shape: the GET history endpoint omits `run_input`
// for an unfinished run, so there is no state to judge from. The
// capability is undetermined (null) and the caller falls back to
// the room-level capability rather than reporting a false negative.
when(
() => mockTransport.request<Map<String, dynamic>>(
'GET',
Uri.parse(
'https://api.example.com/api/v1/rooms/room-123/agui/thread-456',
),
cancelToken: any(named: 'cancelToken'),
fromJson: any(named: 'fromJson'),
body: any(named: 'body'),
headers: any(named: 'headers'),
timeout: any(named: 'timeout'),
),
).thenAnswer(
(_) async => {
'room_id': 'room-123',
'thread_id': 'thread-456',
'runs': {
'run-1': {
'run_id': 'run-1',
'created': '2026-01-07T01:00:00.000Z',
'run_input': null,
'finished': null,
},
},
},
);

final history = await api.getThreadHistory('room-123', 'thread-456');

expect(history.supportsAttachments, isNull);
expect(history.messages, isEmpty);
},
);
});

// ============================================================
// Rooms
// ============================================================
Expand Down
15 changes: 9 additions & 6 deletions packages/soliplex_client/test/domain/thread_history_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,18 +148,21 @@ void main() {
expect(history.supportsAttachments, isTrue);
});

test('is false when aguiState lacks the sandbox namespace', () {
final history = ThreadHistory(messages: const []);
expect(history.supportsAttachments, isFalse);
});

test('is false when only other namespaces are present', () {
test('is false when state is known but lacks the sandbox namespace', () {
final history = ThreadHistory(
messages: const [],
aguiState: const {'rag': <String, dynamic>{}},
);
expect(history.supportsAttachments, isFalse);
});

test('is null (undetermined) when no AG-UI state was resolved', () {
// A freshly created thread whose only run is unfinished carries no
// replayable state; the backend omits run_input on GET, so history
// alone cannot tell — callers fall back to the room capability.
final history = ThreadHistory(messages: const []);
expect(history.supportsAttachments, isNull);
});
});
});

Expand Down
47 changes: 45 additions & 2 deletions test/modules/room/ui/room_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -892,12 +892,17 @@ void main() {
});

testWidgets(
'active thread hides attach when thread state lacks the '
'namespace even if the room has the skill', (tester) async {
'active thread with room skill shows attach when thread history is '
'undetermined (falls back to room capability)', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);

// Empty history = undetermined thread state: a freshly created thread
// whose only run is unfinished carries no replayable state, and the
// backend omits run_input for unfinished runs on GET. The composer must
// fall back to the room capability rather than reporting a false
// negative and hiding attach.
api.nextRoom = const Room(
id: 'room-1',
name: 'Attachable',
Expand All @@ -919,6 +924,44 @@ void main() {
));
await tester.pumpAndSettle();

expect(find.byIcon(Icons.attach_file), findsOneWidget);
});

testWidgets(
'active thread with resolved state lacking the namespace hides attach '
'even when the room has the skill', (tester) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);

// Resolved (non-empty) thread state that lacks the sandbox namespace is
// authoritative: it hides attach even though the room advertises the
// skill. Contrast the empty/undetermined case above, which falls back to
// the room capability.
api.nextRoom = const Room(
id: 'room-1',
name: 'Attachable',
skills: {sandboxSkillName: _sandboxSkill},
);
api.nextThreads = const [];
api.nextThreadHistory = ThreadHistory(
messages: const [],
aguiState: const {'rag': <String, dynamic>{}},
);

await tester.pumpWidget(MaterialApp(
home: RoomScreen(
serverEntry: entry,
roomId: 'room-1',
threadId: 'thread-1',
runtimeManager: runtimeManager,
registry: registry,
uploadRegistry: uploadRegistry,
documentSelections: DocumentSelections(),
),
));
await tester.pumpAndSettle();

expect(find.byIcon(Icons.attach_file), findsNothing);
});

Expand Down
Loading