fix: resolve chat path links against the session's operating workspace (#10924)

* fix: resolve chat path links against the session's operating workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RGq2deVkz8qnpfssssKzn7

* fix: hide the chat link drawer button where nothing can open it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RGq2deVkz8qnpfssssKzn7

* fix: hide the chat tool card open button where nothing can open it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RGq2deVkz8qnpfssssKzn7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-01 23:27:24 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 5d5ad4e897
commit 9074de25ea
7 changed files with 61 additions and 23 deletions
@@ -713,8 +713,8 @@ export class AIChatManager {
workspaceResolver: (() => string | undefined) | undefined = undefined
// The workspace every workspace-scoped chat action targets — skills, tool
// loop, logging, user-message context, and commit. Session-resolved when a
// resolver is set, else the globally-active workspace.
// loop, logging, user-message context, message rendering, and commit.
// Session-resolved when a resolver is set, else the globally-active workspace.
get operatingWorkspace(): string | undefined {
return this.workspaceResolver?.() ?? get(workspaceStore)
}
@@ -13,9 +13,21 @@
import { messageDraft, segments } from './chatDraft'
import { lineCountLabel } from './pasteTokens'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import { workspaceStore } from '$lib/stores'
const aiChatManager = getAiChatManager()
// Paths in a message name items the chat's tools reach, so they resolve against the
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
// navigated workspace, where a fork-only item resolves to nothing and the rest resolve
// to a different copy.
const messageWorkspace = $derived.by(() => {
// Registers the dependency that `operatingWorkspace`'s own untracked
// `get(workspaceStore)` cannot.
void $workspaceStore
return aiChatManager.operatingWorkspace
})
// Per-message expand/collapse state for paste chips shown in the bubble.
let expandedPastes = $state<Set<number>>(new Set())
@@ -119,7 +131,7 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} /></div>
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -6,7 +6,6 @@
import { thinkingPreferences } from './thinkingPreferences.svelte'
import CodeDisplay from './script/CodeDisplay.svelte'
import LinkRenderer from './LinkRenderer.svelte'
import { workspaceStore } from '$lib/stores'
import {
extractCandidatePaths,
remarkWindmillPaths,
@@ -16,9 +15,12 @@
interface Props {
message: DisplayMessage
// Workspace the message's paths are resolved against: the one the chat
// operates on, which is not always the one being navigated.
workspace: string | undefined
}
let { message }: Props = $props()
let { message, workspace }: Props = $props()
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
@@ -69,12 +71,11 @@
// Only populate the registry for messages that contain path-shaped tokens. The
// registry still dedups concurrent calls across messages and workspaces.
$effect(() => {
const ws = $workspaceStore
if (ws && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(ws)
if (workspace && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(workspace)
})
const plugins = $derived.by(() => {
const ws = $workspaceStore ?? ''
const ws = workspace ?? ''
if (!ws || candidatePaths.length === 0) {
return [gfmPlugin(), rendererPlugin]
}
@@ -3,7 +3,10 @@
import { ExternalLink, PanelRight } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { runToolDisplayAction } from './createdResourceActions.svelte'
import {
hasToolDisplayActionHandler,
runToolDisplayAction
} from './createdResourceActions.svelte'
import {
workspaceItemAction,
type WindmillItemKind,
@@ -27,7 +30,12 @@
title
}: Props = $props()
const drawerAction = $derived(workspaceItemAction(wmKind, wmPath, wmTargetKind))
// The drawers ride with the docked chat, so a surface can render this pill with nothing
// able to open one.
const drawerAction = $derived.by(() => {
const action = workspaceItemAction(wmKind, wmPath, wmTargetKind)
return action && hasToolDisplayActionHandler(action.type) ? action : undefined
})
async function openDrawer(event?: Event) {
event?.preventDefault()
@@ -28,7 +28,10 @@
import MqttIcon from '$lib/components/icons/MqttIcon.svelte'
import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte'
import NatsIcon from '$lib/components/icons/NatsIcon.svelte'
import { runToolDisplayAction } from './createdResourceActions.svelte'
import {
hasToolDisplayActionHandler,
runToolDisplayAction
} from './createdResourceActions.svelte'
import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared'
interface Props {
@@ -122,17 +125,21 @@
<div class="truncate text-xs font-semibold text-primary">{card.title}</div>
<div class="truncate text-2xs text-secondary">{card.subtitle}</div>
</div>
<Button
size="xs"
variant="default"
title={action.label}
loading={runningActionId === action.id}
disabled={runningActionId !== undefined && runningActionId !== action.id}
startIcon={{ icon: card.buttonIcon }}
onClick={() => handleAction(action)}
>
Open
</Button>
<!-- open_created_resource is serviced solely by the docked chat's drawers, so
elsewhere the card stands alone as a record of what the tool created. -->
{#if hasToolDisplayActionHandler(action.type)}
<Button
unifiedSize="sm"
variant="default"
title={action.label}
loading={runningActionId === action.id}
disabled={runningActionId !== undefined && runningActionId !== action.id}
startIcon={{ icon: card.buttonIcon }}
onClick={() => handleAction(action)}
>
Open
</Button>
{/if}
</div>
{/each}
</div>
@@ -25,6 +25,15 @@ export function registerToolDisplayActionHandler(
}
}
/**
* Reactive: reads the `$state` registry, so a component re-renders when a page mounts or
* unmounts its handler. Offering an action without checking this yields an affordance whose
* only outcome is the unavailable-action toast.
*/
export function hasToolDisplayActionHandler(type: ToolDisplayAction['type']): boolean {
return toolDisplayActionHandlers[type] !== undefined
}
export async function runToolDisplayAction(action: ToolDisplayAction): Promise<void> {
const handler = toolDisplayActionHandlers[action.type]
if (!handler) {
@@ -9,6 +9,7 @@
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
import { Globe } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
let tab = $state('button')
@@ -195,7 +196,7 @@ That's the full round-trip.`
<code>CodeDisplay</code><code>HighlightCode</code>), constrained to the chat panel width.
</div>
<div class="border border-border-light rounded-lg p-3 bg-surface" style="max-width: 420px;">
<AssistantMessage message={chatMessage} />
<AssistantMessage message={chatMessage} workspace={$workspaceStore} />
</div>
</TabContent>
<TabContent value="scrollbar" class="p-4">