Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ export function DesktopShell({
rightPanelOpen: rightPanel.open,
});
const resourcesFloatingOpen = resourcesPresentation === 'floating' && resourcesOpen;
const resourcesSurfaceOpen =
(resourcesPresentation !== 'hidden' && resourcesOpen) ||
(rightPanel.open && rightPanel.activeSection === 'resources');
const titledSession = active?.title === undefined ? null : active;
const hideMainTitle = draft !== null || (active === null ? false : titledSession === null);
const isRunning = conversation.status === 'running' || conversation.status === 'starting';
Expand Down Expand Up @@ -466,6 +469,7 @@ export function DesktopShell({
isRunning={isRunning}
mentionItems={mentionItems}
onMentionQueryChange={(query) => onMentionQueryChange(active?.cwd, query)}
showPlanInPromptDock={!resourcesSurfaceOpen}
onRespondPermission={onRespondPermission}
onRespondQuestion={onRespondQuestion}
onOpenFileArtifact={openFileArtifact}
Expand Down
2 changes: 2 additions & 0 deletions apps/webview/src/shell/web-workbench-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function WebWorkbenchShell({
floatingSpaceAvailable,
});
const resourcesFloatingOpen = resourcesPresentation === 'floating' && resourcesOpen;
const resourcesSurfaceOpen = resourcesPresentation !== 'hidden' && resourcesOpen;
const resourcesButton = (
<ShellIconButton
label={tPanel('resources')}
Expand All @@ -56,6 +57,7 @@ export function WebWorkbenchShell({
<div className="h-full min-w-0">
<ShellFrame
{...props}
showPlanInPromptDock={!resourcesSurfaceOpen}
onOpenAutomations={() => {
void navigate('/automations');
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useLinkCodeClient } from '@linkcode/client-core';
import type { SessionId, SessionResource } from '@linkcode/schema';
import { MAX_ATTACHMENT_BYTES, SessionResourceIdSchema } from '@linkcode/schema';
import { hostResource, listResources, removeResource, uploadSource } from '@linkcode/sdk';
import type { ResourceItem } from '@linkcode/ui';
import type { CurrentPlan, ResourceItem } from '@linkcode/ui';
import { readFileAsBase64, TaskResourcesPanel } from '@linkcode/ui';
import { toastManager } from 'coss-ui/components/toast';
import { useEffect } from 'foxact/use-abortable-effect';
Expand Down Expand Up @@ -51,8 +51,10 @@ function useSessionResources(sessionId: SessionId) {
/** Runtime-backed adapter from the session resource data plane into the pure shared panel. */
export function RuntimeTaskResourcesPanel({
sessionId,
plan,
}: {
sessionId: SessionId;
plan?: CurrentPlan | null;
}): React.ReactNode {
const t = useTranslations('workbench.resources');
const { data, mutate } = useSessionResources(sessionId);
Expand Down Expand Up @@ -121,6 +123,7 @@ export function RuntimeTaskResourcesPanel({

return (
<TaskResourcesPanel
plan={plan}
resources={(data ?? []).map((resource) => resourceItem(resource, t('localFile')))}
onAddSource={(files) => {
void addSources(files);
Expand Down
7 changes: 6 additions & 1 deletion packages/client/workbench/src/surface/workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
ComposerAttachment,
ComposerDirectiveControls,
ConversationComposerController,
CurrentPlan,
NewSessionDraft,
NewSessionSubmission,
PermissionDecision,
Expand All @@ -39,6 +40,7 @@ import {
extractPinnedGroup,
failedComposerAttachmentFromPath,
groupThreadsByWorkspace,
selectCurrentPlan,
useKeyboardShortcutLabel,
} from '@linkcode/ui';
import { noop } from 'foxact/noop';
Expand Down Expand Up @@ -234,6 +236,7 @@ function WorkbenchSessionSurface({
if (message) visibleResponseErrors.set(requestId, message);
}
const active = sessions.active;
const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation);
const { mentionItems, onMentionQueryChange } = useFileMentionSource();
const newSessionDefaultModels = useConfiguredDefaultModels();
const sdkClient = useWorkbenchSdkClient();
Expand Down Expand Up @@ -614,7 +617,9 @@ function WorkbenchSessionSurface({
return (
<ShellComponent
resourcesPanel={
activeSessionId ? <RuntimeTaskResourcesPanel sessionId={activeSessionId} /> : undefined
activeSessionId ? (
<RuntimeTaskResourcesPanel sessionId={activeSessionId} plan={currentPlan} />
) : undefined
}
attachmentSupport={ATTACHMENT_SUPPORT}
threadGroups={threadGroups}
Expand Down
2 changes: 2 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ export const en = {
},
resources: {
sources: 'Sources',
plan: 'PLAN',
planProgress: 'Plan step {current} of {total}',
outputs: 'Outputs',
emptySources: 'Attach files or connect apps',
emptyOutputs: 'Create a file or site',
Expand Down
2 changes: 2 additions & 0 deletions packages/presentation/i18n/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ export const zhCN = {
},
resources: {
sources: '来源',
plan: '计划',
planProgress: '计划步骤 {current}/{total}',
outputs: '输出',
emptySources: '附加文件或连接应用',
emptyOutputs: '创建文件或网站',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,33 @@ describe('ConversationPromptDock', () => {
).toBe(true);
});

it('keeps actionable prompts when another surface presents the plan', () => {
const currentPlan: ConversationViewModel['items'][number] = {
kind: 'plan',
id: 'plan-1',
turnId: null,
plan: {
planId: 'plan-1',
entries: [{ content: 'Shown in Resources', priority: 'high', status: 'in_progress' }],
},
};

render(
<ConversationPromptDock
conversation={conversation([currentPlan, ITEM], {
pendingQuestionIds: [ITEM.requestId],
})}
showPlan={false}
respondingRequestIds={new Set()}
onRespondPermission={vi.fn()}
onRespondQuestion={vi.fn()}
/>,
);

expect(screen.queryByText('Shown in Resources')).toBeNull();
expect(screen.getByText('Pick features')).toBeDefined();
});

it('shows an authoritative busy state without a local response snapshot', () => {
render(
<ConversationPromptDock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,55 @@ describe('TaskResourcesPanel', () => {
expect(screen.getByText('recently')).toBeDefined();
});

it('renders the current plan between sources and outputs', () => {
render(
<TaskResourcesPanel
resources={[
{
id: 'source-1',
direction: 'source',
name: 'brief.pdf',
kind: 'document',
status: 'ready',
},
{
id: 'output-1',
direction: 'output',
name: 'report.md',
kind: 'document',
status: 'ready',
},
]}
plan={{
currentIndex: 1,
total: 3,
complete: false,
item: {
kind: 'plan',
id: 'plan-1',
turnId: 'turn-1',
plan: {
planId: 'plan-1',
entries: [
{ content: 'Read the code', priority: 'high', status: 'completed' },
{ content: 'Build the panel', priority: 'high', status: 'in_progress' },
{ content: 'Verify the result', priority: 'medium', status: 'pending' },
],
},
},
}}
/>,
);

const source = screen.getByText('brief.pdf');
const plan = screen.getByText('plan');
const output = screen.getByText('report.md');
expect(source.compareDocumentPosition(plan)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(plan.compareDocumentPosition(output)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByText('Build the panel')).toBeDefined();
expect(screen.getByRole('progressbar', { name: 'planProgress' })).toBeDefined();
});

it('shows a failure reason and retries only the failed resource', () => {
const onRetry = vi.fn();
const failed = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@ const EMPTY_RESPONSE_ERRORS = new Map<string, string>();
/** Actionable prompts pinned above the composer: the current plan and pending permission/question asks. */
export function ConversationPromptDock({
conversation,
showPlan = true,
respondingRequestIds,
responseErrors = EMPTY_RESPONSE_ERRORS,
onRespondPermission,
onRespondQuestion,
}: {
conversation: ConversationViewModel;
showPlan?: boolean;
respondingRequestIds: ReadonlySet<string>;
responseErrors?: ReadonlyMap<string, string>;
onRespondPermission: (requestId: string, decision: PermissionDecision) => void;
onRespondQuestion: (requestId: string, outcome: QuestionOutcome) => void;
}): React.ReactNode {
const plan = selectCurrentPlan(conversation);
const plan = showPlan ? selectCurrentPlan(conversation) : null;
const pendingPrompts = selectPendingPromptItems(conversation);
const currentPrompt = pendingPrompts.at(0);

Expand Down
4 changes: 4 additions & 0 deletions packages/presentation/ui/src/shell/conversation-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface ConversationSurfaceProps {
mentionItems?: MentionItem[];
/** Reports the live `@` query so the app can fetch `mentionItems` for it. */
onMentionQueryChange?: (query: string | null) => void;
/** Keeps the compact plan above the composer when another visible surface does not own it. */
showPlanInPromptDock?: boolean;
onRespondPermission: (requestId: string, decision: PermissionDecision) => void;
onRespondQuestion: (requestId: string, outcome: QuestionOutcome) => void;
/** Opens a produced-file artifact in the shell's viewer (desktop right panel). Absent
Expand Down Expand Up @@ -98,6 +100,7 @@ export function ConversationSurface({
TerminalBlockComponent,
mentionItems,
onMentionQueryChange,
showPlanInPromptDock = true,
onRespondPermission,
onRespondQuestion,
onOpenFileArtifact,
Expand Down Expand Up @@ -142,6 +145,7 @@ export function ConversationSurface({
</div>
<ConversationPromptDock
conversation={conversation}
showPlan={showPlanInPromptDock}
respondingRequestIds={respondingRequestIds}
responseErrors={responseErrors}
onRespondPermission={onRespondPermission}
Expand Down
4 changes: 4 additions & 0 deletions packages/presentation/ui/src/shell/shell-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export interface ShellFrameProps
mentionItems: MentionItem[];
/** Queries mentions against the cwd belonging to whichever composer is visible. */
onMentionQueryChange: (cwd: string | undefined, query: string | null) => void;
/** Whether the compact current plan remains above the composer. */
showPlanInPromptDock?: boolean;
/** Complete active-session composer behavior, forwarded atomically by every shell. */
conversationComposer: ConversationComposerController;
onRespondPermission: (requestId: string, decision: PermissionDecision) => void;
Expand Down Expand Up @@ -157,6 +159,7 @@ export function ShellFrame({
onTogglePreviewExpanded,
mentionItems,
onMentionQueryChange,
showPlanInPromptDock,
conversationComposer,
onRespondPermission,
onRespondQuestion,
Expand Down Expand Up @@ -253,6 +256,7 @@ export function ShellFrame({
TerminalBlockComponent={TerminalBlockComponent}
mentionItems={mentionItems}
onMentionQueryChange={(query) => onMentionQueryChange(active?.cwd, query)}
showPlanInPromptDock={showPlanInPromptDock}
onRespondPermission={onRespondPermission}
onRespondQuestion={onRespondQuestion}
onHostArtifact={onHostArtifact}
Expand Down
47 changes: 47 additions & 0 deletions packages/presentation/ui/src/shell/task-resources-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
} from 'lucide-react';
import { useRef } from 'react';
import { useTranslations } from 'use-intl';
import { CircularProgress } from '../chat/circular-progress';
import type { CurrentPlan } from '../chat/conversation-prompts';
import { StepItem } from '../chat/step';
import { cn } from '../lib/cn';
import { useRelativeTimeLabel } from './use-relative-time-label';

export type ResourceDirection = 'source' | 'output';
Expand All @@ -40,6 +44,7 @@ export interface ResourceItem {

export interface TaskResourcesPanelProps {
resources: ResourceItem[];
plan?: CurrentPlan | null;
onAddSource?: (files: File[]) => void;
onCreateOutput?: (kind: OutputResourceKind) => void;
onOpen?: (resource: ResourceItem) => void;
Expand All @@ -60,6 +65,7 @@ const KIND_ICONS: Record<ResourceKind, React.ReactNode> = {

export function TaskResourcesPanel({
resources,
plan,
onAddSource,
onCreateOutput,
onOpen,
Expand All @@ -83,6 +89,7 @@ export function TaskResourcesPanel({
onRemove={onRemove}
onRetry={onRetry}
/>
{plan ? <TaskPlanSection plan={plan} /> : null}
<ResourceSection
direction="output"
resources={outputs}
Expand All @@ -97,6 +104,46 @@ export function TaskResourcesPanel({
);
}

function TaskPlanSection({ plan }: { plan: CurrentPlan }): React.ReactNode {
const t = useTranslations('workbench.resources');
const progress = plan.currentIndex + 1;

return (
<section className="border-border border-b">
<div className="flex h-10 items-center gap-2 px-3">
<h2 className="font-medium text-sm">{t('plan')}</h2>
<div className="ml-auto flex items-center gap-1.5 text-label-tertiary text-xs tabular-nums">
<CircularProgress
aria-label={t('planProgress', { current: progress, total: plan.total })}
className="size-3.5"
value={progress}
max={plan.total}
strokeWidth={2}
/>
<span aria-hidden>
{progress}/{plan.total}
</span>
</div>
</div>
<div className="px-3 pb-3">
{plan.item.plan.entries.map((entry, index) => (
<StepItem
// eslint-disable-next-line @eslint-react/no-array-index-key -- plan entries have stable positions but no ids
key={index}
className={cn(
'items-start py-1 text-xs',
entry.status === 'pending' && 'text-muted-foreground',
)}
status={entry.status}
>
{entry.content}
</StepItem>
))}
</div>
</section>
);
}

function ResourceSection({
direction,
resources,
Expand Down