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
29 changes: 28 additions & 1 deletion src-tauri/capabilities/session-window.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,34 @@
"identifier": "opener:allow-open-path",
"allow": [
{
"path": "$DOWNLOAD"
"path": "$HOME/**"
},
{
"path": "$HOME/.goose/**"
},
{
"path": "$TEMP/**"
},
{
"path": "/Volumes/**"
},
{
"path": "/mnt/**"
},
{
"path": "/workspace/**"
},
{
"path": "/workspaces/**"
},
{
"path": "/opt/**"
},
{
"path": "/srv/**"
},
{
"path": "*:/**"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/gen/schemas/capabilities.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","core:window:allow-set-size","core:window:allow-set-min-size","deep-link:default","opener:default",{"identifier":"opener:allow-open-url","allow":[{"url":"linear://*"},{"url":"https://linear.app/*"}]},{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","clipboard-manager:allow-write-text","app-test-driver:default","berdctl:default","updater:default","process:allow-restart","notification:default"]},"session-window":{"identifier":"session-window","description":"Capability for popped-out chat session windows","local":true,"windows":["session:*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-show","core:window:allow-close","core:window:allow-set-focus","core:window:allow-set-size","core:window:allow-set-min-size","opener:default",{"identifier":"opener:allow-open-url","allow":[{"url":"linear://*"},{"url":"https://linear.app/*"}]},{"identifier":"opener:allow-open-path","allow":[{"path":"$DOWNLOAD"}]},"dialog:allow-open","dialog:allow-save","berdctl:allow-status"],"platforms":["macOS"]},"voice-buddy":{"identifier":"voice-buddy","description":"Capability for the always-on-top voice conversation buddy","local":true,"windows":["voice-buddy"],"permissions":["core:default","core:window:allow-start-dragging"]}}
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:window:allow-show","core:window:allow-close","core:window:allow-set-size","core:window:allow-set-min-size","deep-link:default","opener:default",{"identifier":"opener:allow-open-url","allow":[{"url":"linear://*"},{"url":"https://linear.app/*"}]},{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"window-state:allow-restore-state","window-state:allow-save-window-state","dialog:allow-open","dialog:allow-save","clipboard-manager:allow-write-text","app-test-driver:default","berdctl:default","updater:default","process:allow-restart","notification:default"]},"session-window":{"identifier":"session-window","description":"Capability for popped-out chat session windows","local":true,"windows":["session:*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-show","core:window:allow-close","core:window:allow-set-focus","core:window:allow-set-size","core:window:allow-set-min-size","opener:default",{"identifier":"opener:allow-open-url","allow":[{"url":"linear://*"},{"url":"https://linear.app/*"}]},{"identifier":"opener:allow-open-path","allow":[{"path":"$HOME/**"},{"path":"$HOME/.goose/**"},{"path":"$TEMP/**"},{"path":"/Volumes/**"},{"path":"/mnt/**"},{"path":"/workspace/**"},{"path":"/workspaces/**"},{"path":"/opt/**"},{"path":"/srv/**"},{"path":"*:/**"}]},"dialog:allow-open","dialog:allow-save","berdctl:allow-status"],"platforms":["macOS"]},"voice-buddy":{"identifier":"voice-buddy","description":"Capability for the always-on-top voice conversation buddy","local":true,"windows":["voice-buddy"],"permissions":["core:default","core:window:allow-start-dragging"]}}
10 changes: 9 additions & 1 deletion src/features/chat/hooks/ArtifactPolicyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,15 @@ export function ArtifactPolicyProvider({
return;
}
lastOpenAtByPathRef.current.set(key, now);
await openPath(resolvedTarget);
try {
await openPath(resolvedTarget);
} catch (error) {
// A failed hand-off must not consume the debounce window: the user
// should be able to retry immediately (e.g. after fixing whatever the
// OS rejected) instead of having the retry silently absorbed.
lastOpenAtByPathRef.current.delete(key);
throw error;
}
},
[filesAreRemote, remoteHost, resolveOpenTarget, normalizedSessionCwd, t],
);
Expand Down
32 changes: 29 additions & 3 deletions src/features/chat/ui/ArtifactViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ export function ArtifactViewer({
const [diskStatus, setDiskStatus] = useState<DiskStatus>("checking");
const diskStatusRef = useRef<DiskStatus>(diskStatus);
const [divergedKind, setDivergedKind] = useState<FileStatErrorKind>("other");
// Known-gone from disk (initial load or polling verdict). Gates every
// affordance that targets the file itself: the error body's "Open in
// editor" and both OS hand-offs in the ⋯ menu.
const fileIsMissing = diskStatus === "diverged" && divergedKind === "missing";
const divergenceStrikesRef = useRef(0);
const [imageDiskRevision, setImageDiskRevision] = useState(0);
const imageDiskRevisionRef = useRef(0);
Expand Down Expand Up @@ -598,7 +602,14 @@ export function ArtifactViewer({
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Both OS hand-offs target the file itself, so a deleted
file makes them guaranteed dead clicks (the editor can't
open it; revealItemInDir receives the missing path and
its rejection is swallowed). Disable rather than hide so
the menu shape stays stable; polling keeps watching the
path and re-enables both if the file reappears. */}
<DropdownMenuItem
disabled={fileIsMissing}
onSelect={() => {
void openResolvedPath(artifact.resolvedPath).catch(
() => {},
Expand All @@ -609,6 +620,7 @@ export function ArtifactViewer({
{t("artifactViewer.openExternally")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={fileIsMissing}
onSelect={() => {
void revealInFileManager(artifact.resolvedPath).catch(
() => {},
Expand Down Expand Up @@ -694,6 +706,10 @@ export function ArtifactViewer({
<MarkdownBody
markdownView={markdownView}
textState={renderedTextState}
// A deleted file cannot be handed to an editor; the "file
// deleted" strip above is the whole answer (mirrors the strip
// hiding its Reload button for the same reason).
fileIsMissing={fileIsMissing}
onOpenExternally={() => {
void openResolvedPath(artifact.resolvedPath).catch(() => {});
}}
Expand Down Expand Up @@ -776,10 +792,13 @@ function ImageBody({
function MarkdownBody({
markdownView,
textState,
fileIsMissing = false,
onOpenExternally,
}: {
markdownView: MarkdownView;
textState: TextState;
/** The file is gone from disk, so opening it externally cannot succeed. */
fileIsMissing?: boolean;
onOpenExternally: () => void;
}) {
const { t } = useTranslation("chat");
Expand All @@ -797,9 +816,16 @@ function MarkdownBody({
<p className="text-center text-sm text-muted-foreground">
{t("artifactViewer.loadError")}
</p>
<Button variant="outline" size="sm" onClick={onOpenExternally}>
{t("artifactViewer.openExternally")}
</Button>
{/* "Open in editor" is an escape hatch for files Berd can't render
itself (encoding, size, permissions) — but a DELETED file can't be
opened by anything, so offering it would be a guaranteed dead
click. Polling keeps watching; if the file reappears the view
heals and the action returns. */}
{!fileIsMissing ? (
<Button variant="outline" size="sm" onClick={onOpenExternally}>
{t("artifactViewer.openExternally")}
</Button>
) : null}
</div>
);
}
Expand Down
46 changes: 22 additions & 24 deletions src/features/chat/ui/ChatTranscriptSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { Persona } from "@/shared/types/agents";
import type { Message } from "@/shared/types/messages";
import { scheduleAfterNextPaint } from "@/app/lib/scheduleAfterNextPaint";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { ArtifactPolicyProvider } from "@/features/chat/hooks/ArtifactPolicyContext";
import type { TranscriptSearchBackend } from "@/features/chat/lib/transcriptSearchBackend";
import { useSessionFeedbackSurvey } from "../response-feedback/useSessionFeedbackSurvey";
import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
Expand Down Expand Up @@ -162,30 +161,29 @@ export function ChatTranscriptSurface({
</div>
);

// No ArtifactPolicyProvider here: exactly one provider is mounted per
// rendered session boundary, owned by the surface that composes the
// transcript with its siblings (ChatView's chat row, ChatCanvasCard).
// Two providers for one visible session would derive artifact inventory
// twice and split the per-path open debounce across surfaces.
return (
<ArtifactPolicyProvider
messages={timelineMessages}
sessionCwd={sessionCwd ?? null}
<VirtualMessageTimelineGate
sessionId={sessionId}
>
<VirtualMessageTimelineGate
sessionId={sessionId}
messages={timelineMessages}
streamingMessageId={streamingMessageId}
sessionFeedbackSurvey={sessionFeedbackSurvey}
scrollTargetMessageId={scrollTargetMessageId}
scrollTargetQuery={scrollTargetQuery}
onScrollTargetHandled={onScrollTargetHandled}
searchContentRef={searchContentRef}
searchBackendRef={searchBackendRef}
showPlaceholder={showLoading}
placeholder={placeholder}
startContent={startContent}
footer={footer}
footerStatus={footerStatus}
rendererPolicy={rendererPolicy}
{...callbacks}
/>
</ArtifactPolicyProvider>
messages={timelineMessages}
streamingMessageId={streamingMessageId}
sessionFeedbackSurvey={sessionFeedbackSurvey}
scrollTargetMessageId={scrollTargetMessageId}
scrollTargetQuery={scrollTargetQuery}
onScrollTargetHandled={onScrollTargetHandled}
searchContentRef={searchContentRef}
searchBackendRef={searchBackendRef}
showPlaceholder={showLoading}
placeholder={placeholder}
startContent={startContent}
footer={footer}
footerStatus={footerStatus}
rendererPolicy={rendererPolicy}
{...callbacks}
/>
);
}
38 changes: 32 additions & 6 deletions src/features/chat/ui/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CONVERSATION_MIN_WIDTH_WITH_VIEWER,
} from "./ArtifactViewerPanel";
import { useOpenArtifact } from "../stores/artifactViewerStore";
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
import { ArtifactAutoOpenMount } from "./ArtifactAutoOpenMount";
import {
CP_TOTAL_W,
Expand Down Expand Up @@ -124,7 +125,6 @@ export function ChatView({
}: ChatViewProps) {
const { t } = useTranslation("chat");
useRegisterSecurityConfirmationSurface(sessionId);
const isArtifactViewerOpen = useOpenArtifact(sessionId) !== null;
const mountStart = useRef(performance.now());
const terminalRootRef = useRef<HTMLDivElement | null>(null);
const chatColumnRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -201,6 +201,14 @@ export function ChatView({
]);
const workspaceRepository = useWorkspaceRepository();
const effectiveSession = controller.session ?? activeSession ?? null;
// The effective session identity: during session replacement or
// reconciliation the requested sessionId can briefly disagree with the
// snapshot the controller serves. Every artifact-store read/write and
// every layout decision derived from viewer state must use THIS id, so
// the panel, the policy provider, and the width math all describe the
// same store entry. (Audited: all useOpenArtifact call sites in ChatView.)
const timelineSessionId = effectiveSession?.id ?? sessionId;
const isArtifactViewerOpen = useOpenArtifact(timelineSessionId) !== null;
const isReadOnly = Boolean(readOnlyStatus);
// A remote session's cwd and artifact paths live on its SSH host: the
// in-chat terminal (a local PTY), local folder pickers, and local file
Expand Down Expand Up @@ -728,7 +736,6 @@ export function ChatView({
</div>
);

const timelineSessionId = effectiveSession?.id ?? sessionId;
const messageTimeline = (
<ChatTranscriptSurface
sessionId={timelineSessionId}
Expand Down Expand Up @@ -793,11 +800,28 @@ export function ChatView({
});

return (
<>
// The single artifact policy owner for this session boundary. It must
// wrap the whole chat row because the transcript AND its siblings
// consume the context: ArtifactViewerPanel ("Open in editor"), the
// right rail's ArtifactsWidget (row opens), and ArtifactAutoOpenMount
// (the artifact list). ChatTranscriptSurface intentionally does NOT own
// artifact policy — a nested provider would derive a second artifact
// inventory and split the per-path open debounce (enforced by the
// provider-boundary tests). Consumers outside the provider get the
// inert default context and silently no-op.
// The identity, messages, and cwd must describe one session snapshot:
// timelineSessionId (the controller's effective session) rather than the
// raw requested sessionId, which can briefly disagree with
// controller.messages during session replacement or reconciliation.
<ArtifactPolicyProvider

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 · Artifact policy has duplicate owners (non-blocking)

The new ChatView provider wraps a ChatTranscriptSurface that still creates its own provider, and ChatCanvasCard similarly gives its transcript and composer separate providers. Each provider independently derives artifact inventory and owns its own per-path open debounce. The same visible session can therefore process different message projections and bypass duplicate-open suppression when actions originate from different surfaces.

User effect: Opening the same artifact quickly from two parts of one chat can hand it to the operating system twice, and artifact availability can drift between the transcript and adjacent viewer, rail, or composer surfaces.

Recommended fix: Mount one ArtifactPolicyProvider per rendered session boundary, placing transcript, composer, viewer, auto-open mount, and artifact widgets beneath it. Keep standalone transcript support through an explicit wrapper rather than implicit nested ownership.

Test: Add a provider-boundary test proving one policy owner covers all full-chat consumers and one covers both canvas transcript and composer; verify opening one path across two consumer surfaces shares the same debounce lifecycle.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 · Provider state crosses session replacement (non-blocking)

The moved ArtifactPolicyProvider receives timelineSessionId, but React reuses the same provider instance when that prop changes. Provider-local refs, including the per-path open debounce map, therefore survive when reconciliation replaces the effective session without unmounting ChatView. A path opened in the previous session remains temporarily marked as recently opened in the replacement session.

User effect: If someone opens the same file path immediately after a chat session is replaced or reconciled, the first open in the new session can appear to do nothing because it is suppressed by the previous session's debounce state.

Recommended fix: Key the ArtifactPolicyProvider by timelineSessionId, or explicitly reset all session-scoped provider state when the session identity changes, so each effective session starts with an independent artifact-action lifecycle.

Test: Add a reconciliation test that opens a path, changes the effective session identity without unmounting ChatView, and verifies that opening the same path in the replacement session reaches the OS boundary immediately.

messages={controller.messages}
sessionCwd={controller.sessionArtifactCwd}
sessionId={timelineSessionId}
>
<ArtifactAutoOpenMount
// Remote artifacts cannot be read locally, so never auto-open the
// viewer for them; a null session absorbs appearances silently.
sessionId={sessionIsRemote ? null : sessionId}
sessionId={sessionIsRemote ? null : timelineSessionId}
isHistoryLoading={controller.isLoadingHistory}
sessionCwd={controller.sessionArtifactCwd}
/>
Expand Down Expand Up @@ -943,7 +967,9 @@ export function ChatView({
</div>

{sessionId && !isAgentBuilderSession ? (
<ArtifactViewerPanel sessionId={sessionId} />
// Keyed by the same effective identity the providers use, so the
// panel reads the viewer-store entry that openInApp writes.
<ArtifactViewerPanel sessionId={timelineSessionId} />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Viewer layout reads stale session (blocking)

ArtifactViewerPanel now correctly reads timelineSessionId, but isArtifactViewerOpen is still derived near the top of ChatView with useOpenArtifact(sessionId). During the reconciliation state this follow-up explicitly supports, the panel and the layout calculation therefore read different artifact-store entries. Width allocation and compact-mode decisions can disagree with the viewer actually rendered.

User effect: While a chat is being restored or reconciled, an open artifact viewer can appear without reserved space and overlap or compress the chat, while a stale viewer can leave an empty reserved column.

Recommended fix: Derive the effective timeline session identity before the viewer-store lookup and use it consistently for useOpenArtifact, ArtifactViewerPanel, and every layout calculation driven by viewer visibility.

Test: Extend the reconciliation test so requested and effective session IDs differ, seed viewer state only for the effective session, and assert that the rendered chat layout enters its viewer-open configuration; also verify stale requested-session viewer state does not reserve space.

) : null}

<ChatRightRail
Expand Down Expand Up @@ -979,6 +1005,6 @@ export function ChatView({
}
/>
</div>
</>
</ArtifactPolicyProvider>
);
}
Loading