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
3 changes: 3 additions & 0 deletions plugins/agent/conversations/ui/SessionSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,7 @@ export function createConversationUi(
// Kept here so the browser's collapsed workspaces outlive its unmounting
// and stepping between conversations skips them as the browser does.
const collapsedGroups = signal<ReadonlySet<string>>(new Set());
const expandedProjects = signal<ReadonlySet<string>>(new Set());
const stepSession = (step: 1 | -1) => {
const current = overlay();
if (switching() || busy() || (current && current.kind !== 'sessions')) return;
Expand All @@ -1950,6 +1951,7 @@ export function createConversationUi(
),
collapsedGroups(),
selected(),
platform.idiom === 'terminal' ? undefined : expandedProjects(),
);
const index = rows.indexOf(selected() ?? '');
const next = rows[index === -1 ? 0 : index + step];
Expand Down Expand Up @@ -2009,6 +2011,7 @@ export function createConversationUi(
beside: () => shell()?.besideSessionIds?.() ?? [],
openBeside: (sessionId) => shell()?.openBeside?.(sessionId),
collapsedGroups,
expandedProjects,
};
let wasDocked = shell()?.sidebarDocked();
let previousWidth = windowSize().width;
Expand Down
5 changes: 4 additions & 1 deletion plugins/agent/state/src/session_catalog/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ const BASE: &str = "WITH base AS (
(SELECT e.schema_version FROM agent_catalog_events e WHERE e.owner=?1 AND e.session=s.id ORDER BY revision DESC LIMIT 1) AS control_schema,
s.catalog_unpinned AS unpinned,
source.run AS source_run,COALESCE(source.sequence,0)>COALESCE(k.read_through,0) AS source_unread,
-max(s.created_at,COALESCE((SELECT MAX(at) FROM agent_catalog_messages m WHERE m.owner=?1 AND m.session=s.id AND m.sequence>COALESCE(source.sequence,0)),0),COALESCE(CAST(json_extract(source_summary.summary_json,'$.summary.updatedAt') AS INTEGER),0)) AS recency
-max(s.created_at,COALESCE((SELECT m.at FROM agent_catalog_runs admitted
JOIN agent_catalog_messages m ON m.owner=admitted.owner AND m.session=admitted.session AND m.sequence=admitted.admission_sequence
WHERE admitted.owner=?1 AND admitted.session=s.id AND admitted.admission_sequence>COALESCE(source.sequence,0)
ORDER BY admitted.admission_sequence DESC LIMIT 1),0),COALESCE(CAST(json_extract(source_summary.summary_json,'$.summary.updatedAt') AS INTEGER),0)) AS recency
FROM session_control s
JOIN session_creations route ON route.session_id=s.id AND route.ready=1 AND route.visible=1
LEFT JOIN workspace_bindings b ON b.id=json_extract(s.configuration,'$.workspace.id')
Expand Down
1 change: 1 addition & 0 deletions products/maka/terminal-shell/tests/fixtures/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ function WorkspacePages(props: { busy: boolean }) {
filter: () => 'all',
needsYouCount: () => 0,
sessions,
hasMore: () => page < 3,
loading,
projects: () =>
props.busy
Expand Down
116 changes: 100 additions & 16 deletions products/maka/workbench/app/WorkspaceBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ import {
import { Icon } from './Icon';
import type { Browsing, BrowserProject, BrowserScope, BrowserWorkspace } from './browsing';
import { SessionActions } from './SessionActions';
import { groupWorkspace, groupConversations, type ConversationGroup } from './conversationGroups';
import {
groupWorkspace,
groupConversations,
projectSessionLimit,
type ConversationGroup,
} from './conversationGroups';
import {
hasForkPrefix,
sessionActionTarget,
Expand Down Expand Up @@ -97,6 +102,7 @@ export type WorkspaceBrowserProps = {
* next and previous conversation skip them.
*/
collapsedGroups?: Signal<ReadonlySet<string>>;
expandedProjects?: Signal<ReadonlySet<string>>;
};

type WorkspaceGroup = ConversationGroup<BrowserProject> & { name: string };
Expand Down Expand Up @@ -174,10 +180,8 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
const scopesById = computed(() => new Map(scopes().map((scope) => [scope.id, scope])));
const firstUnpinned = computed(() => visibleSessions().find((session) => !session.pinned)?.id);
const hasPinned = () => visibleSessions()[0]?.pinned === true;
// The desktop lists conversations under their workspaces instead of
// choosing a scope: pinned ones first, then each workspace in the order of
// its latest loaded conversation, then active projects with none loaded.
const collapsed = props.collapsedGroups ?? signal<ReadonlySet<string>>(new Set());
const expanded = props.expandedProjects ?? signal<ReadonlySet<string>>(new Set());
const groups = computed(() =>
groupConversations(visibleSessions(), browser.projects(), browser.filter() === 'all').map(
(group): WorkspaceGroup => ({
Expand All @@ -189,24 +193,33 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
),
);
const nestedRows = computed(() => new Set(groups().flatMap((group) => [...group.nested])));
const groupsById = computed(
() => new Map(groups().map((group) => [`group:${group.key}`, group])),
);
const groupsById = computed(() => new Map(groups().map((group) => [group.key, group])));
const rowIds = computed(() =>
platform.idiom === 'terminal'
? choosingWorkspace()
? scopes().map((scope) => scope.id)
: visibleSessions().map((session) => session.id)
: groups().flatMap((group) => [
`group:${group.key}`,
...(collapsed().has(group.key) ? [] : group.sessions),
]),
: groups().flatMap((group) => {
const heading = `group:${group.key}`;
if (collapsed().has(group.key)) return [heading];
const limit = projectSessionLimit(group);
if (group.sessions.length <= limit) return [heading, ...group.sessions];
const sessions = expanded().has(group.key)
? group.sessions
: group.sessions.slice(0, limit);
return [heading, ...sessions, `show-more:${group.key}`];
}),
);
const toggleGroup = (key: string) => {
const next = new Set(collapsed());
if (!next.delete(key)) next.add(key);
collapsed.set(next);
};
const toggleProjectSessions = (key: string) => {
const next = new Set(expanded());
if (!next.delete(key)) next.add(key);
expanded.set(next);
};
const select = async ({ id, origin }: { id: string; origin: string | null }) => {
if (!alive || props.selected() !== origin || props.switching()) return;
navigationNotice.set(null);
Expand Down Expand Up @@ -278,12 +291,37 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
// Hidden rows can extend a page without making the List emit its end edge again.
const endRequest = signal<{
rows: number;
loaded: number;
scope: BrowserScope;
filter: ReturnType<Browsing['filter']>;
} | null>(null);
const loadMore = () => {
if (!choosingWorkspace())
endRequest.set({ rows: rowIds().length, scope: browser.scope(), filter: browser.filter() });
endRequest.set({
rows: rowIds().length,
loaded: browser.sessions().length,
scope: browser.scope(),
filter: browser.filter(),
});
};
const showOlder = () => {
const request = endRequest();
return (
platform.idiom !== 'terminal' &&
!!request &&
browser.hasMore() &&
!browser.error() &&
request.scope === browser.scope() &&
request.filter === browser.filter() &&
rowIds().length === request.rows &&
browser.sessions().length > request.loaded &&
groups().some(
(group) =>
!collapsed().has(group.key) &&
!expanded().has(group.key) &&
group.sessions.length > projectSessionLimit(group),
)
);
};
effect(() => {
const rows = rowIds().length;
Expand All @@ -301,8 +339,10 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
endRequest.set({ ...requested, rows });
return;
}
if (choosingWorkspace() || browser.loading() || browser.error()) return;
if (requested || rows < Math.ceil(viewportHeight() / rowHeight())) {
if (choosingWorkspace() || browser.loading() || browser.error() || showOlder()) return;
if (!requested && browser.hasMore() && rows < Math.ceil(viewportHeight() / rowHeight())) {
loadMore();
} else if (requested) {
void browser.loadMore().then((result) => {
if (alive && endRequest() === requested && result === 'complete') endRequest.set(null);
});
Expand Down Expand Up @@ -595,6 +635,18 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
)}
onLayout={({ bounds: frame }) => viewportHeight.set(frame.size.height)}
onLoadMore={loadMore}
footer={() => (
<If when={showOlder()}>
<Button
onPress={loadMore}
style={{ height: rowHeight(), justifyContent: 'center', paddingHorizontal: 8 }}
hoverStyle={controlHoverStyle()}
focusStyle={controlFocusStyle()}
>
<Text style={{ fontSize: 14, color: C.muted }}>{tr('Load older conversations')}</Text>
</Button>
</If>
)}
style={{ flex: 1, minHeight: 32 }}
accessibilityLabel={choosingWorkspace() ? tr('Workspace scopes') : tr('Conversations')}
>
Expand Down Expand Up @@ -660,9 +712,41 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) {
</View>
);
}
const initialGroup = groupsById().get(key());
if (key().startsWith('show-more:')) {
const groupKey = key().slice('show-more:'.length);
const open = () => expanded().has(groupKey);
return (
<Button
accessibilityLabel={tr(
open()
? 'Show fewer conversations in {name}'
: 'Show more conversations in {name}',
{ name: groupsById().get(groupKey)?.name ?? '' },
)}
onPress={inputAction(toggleProjectSessions, groupKey)}
style={{
height: rowHeight(),
justifyContent: 'center',
paddingHorizontal: 8,
borderRadius: 8,
}}
hoverStyle={controlHoverStyle()}
focusStyle={controlFocusStyle()}
>
<Text style={{ fontSize: 14, color: C.muted }}>
{tr(
open()
? 'Show fewer project conversations'
: 'Show more project conversations',
)}
</Text>
</Button>
);
}
const groupKey = key().startsWith('group:') ? key().slice('group:'.length) : '';
const initialGroup = groupsById().get(groupKey);
if (initialGroup) {
const group = () => groupsById().get(key()) ?? initialGroup;
const group = () => groupsById().get(groupKey) ?? initialGroup;
const open = () => !collapsed().has(group().key);
const workspace = () => groupWorkspace(group());
return (
Expand Down
10 changes: 9 additions & 1 deletion products/maka/workbench/app/browsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type Browsing = {
sessions(): BrowserSession[];
needsYouCount(): number;
projects(): BrowserProject[];
hasMore(): boolean;
loading(): boolean;
error(): string;
projectError(): string;
Expand Down Expand Up @@ -82,6 +83,7 @@ export function createBrowsing(host: Host): Browsing {
const sessions = signal<BrowserSession[]>([]);
const needsYouCount = signal(0);
const projects = signal<BrowserProject[]>([]);
const hasMore = signal(false);
const loading = signal(true);
const error = createText();
const projectError = createText();
Expand Down Expand Up @@ -150,6 +152,7 @@ export function createBrowsing(host: Host): Browsing {
error.set('');
if (clear) {
sessions.set([]);
hasMore.set(false);
needsYouCount.set(0);
}
});
Expand Down Expand Up @@ -218,6 +221,7 @@ export function createBrowsing(host: Host): Browsing {
read.extend = false;
batch(() => {
sessions.set(snapshot);
hasMore.set(tail.after !== null);
needsYouCount.set(next.needsYouCount);
loading.set(false);
error.set('');
Expand Down Expand Up @@ -279,7 +283,10 @@ export function createBrowsing(host: Host): Browsing {
page = next;
read.extent = snapshot.at(-1)?.id ?? null;
read.extend = false;
sessions.set(snapshot);
batch(() => {
sessions.set(snapshot);
hasMore.set(next.after !== null);
});
return 'advanced';
} catch (reason) {
if (!owns(read) || continuation !== original) return 'waiting';
Expand Down Expand Up @@ -371,6 +378,7 @@ export function createBrowsing(host: Host): Browsing {
sessions,
needsYouCount,
projects,
hasMore,
loading,
error,
projectError,
Expand Down
39 changes: 28 additions & 11 deletions products/maka/workbench/app/conversationGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,9 @@ export type ConversationGroup<Project> = {
nested: ReadonlySet<string>;
};

/**
* The browser's groups: pinned conversations first, then each workspace in
* the order of its latest loaded conversation, then, when `withEmpty`, the
* active projects with none loaded.
*/
const PROJECT_SESSION_LIMIT = 6;

/** Pinned conversations, registered projects in catalog order, then other workspaces. */
export function groupConversations<Project extends Listable>(
sessions: readonly Groupable[],
projects: readonly Project[],
Expand Down Expand Up @@ -85,24 +83,43 @@ export function groupConversations<Project extends Listable>(
for (const project of byProject.values())
if (!project.archived && !groups.has(`project:${project.reference.id}`))
add(`project:${project.reference.id}`, false, project, null);
return [...groups.values()].map(({ members, ...group }) => {
const ordered = [];
const pinned = groups.get('pinned');
if (pinned) ordered.push(pinned);
for (const project of byProject.values()) {
const group = groups.get(`project:${project.reference.id}`);
if (group) ordered.push(group);
}
for (const group of groups.values()) {
if (!group.pinned && !group.project) ordered.push(group);
}
return ordered.map(({ members, ...group }) => {
const { order, nested } = nestCreated(members);
return { ...group, sessions: order, nested };
});
}

export function projectSessionLimit(group: ConversationGroup<unknown>): number {
return group.key.startsWith('project:') ? PROJECT_SESSION_LIMIT : group.sessions.length;
}

/**
* The conversations in the order the browser lists them: a collapsed group's
* are left out, except `current`, so stepping from it still has neighbors.
* Step through the visible conversations, keeping a hidden current row as a starting point.
*/
export function browserOrder(
groups: readonly ConversationGroup<unknown>[],
collapsed: ReadonlySet<string>,
current: string | null,
expandedProjects?: ReadonlySet<string>,
): string[] {
return groups.flatMap((group) =>
collapsed.has(group.key) ? group.sessions.filter((id) => id === current) : group.sessions,
);
return groups.flatMap((group) => {
const limit = collapsed.has(group.key)
? 0
: expandedProjects && !expandedProjects.has(group.key)
? projectSessionLimit(group)
: group.sessions.length;
return group.sessions.filter((id, index) => index < limit || id === current);
});
}

/** Resolve creation through an owned workspace, never a display path. */
Expand Down
13 changes: 13 additions & 0 deletions products/maka/workbench/app/locale-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ export const workspaceMessages = {
'Close side pane': ['Close side pane', '关闭右侧对话', '關閉右側對話'],
'Collapse {name}': ['Collapse {name}', '收起 {name}', '收合 {name}'],
'Expand {name}': ['Expand {name}', '展开 {name}', '展開 {name}'],
'Show more project conversations': ['Show more', '展开显示', '展開顯示'],
'Show fewer project conversations': ['Show less', '收起', '收合'],
'Load older conversations': ['Load older conversations', '加载更早的会话', '載入更早的對話'],
'Show more conversations in {name}': [
'Show more conversations in {name}',
'展开 {name} 中的更多会话',
'展開 {name} 中的更多對話',
],
'Show fewer conversations in {name}': [
'Show fewer conversations in {name}',
'收起 {name} 中的更多会话',
'收合 {name} 中的更多對話',
],
'Unpin conversation': ['Unpin conversation', '取消置顶', '取消置頂'],
Pinned: ['Pinned', '已置顶', '已置頂'],
'Actions for {title}': ['Actions for {title}', '{title} 的操作', '{title} 的操作'],
Expand Down
Loading
Loading