Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs
# Conflicts: # packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx # packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
|
||||
@@ -555,19 +555,56 @@ interface FilePart {
|
||||
source?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
||||
const FORGE_LINK_MIMES = new Set([
|
||||
'application/vnd.github.issue-link',
|
||||
'application/vnd.github.pull-request-link',
|
||||
'application/vnd.gitlab.issue-link',
|
||||
'application/vnd.gitlab.merge-request-link',
|
||||
'application/vnd.gitea.issue-link',
|
||||
'application/vnd.gitea.pull-request-link',
|
||||
]);
|
||||
|
||||
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
||||
return 'issue';
|
||||
const ISSUE_LINK_MIMES = new Set([
|
||||
'application/vnd.github.issue-link',
|
||||
'application/vnd.gitlab.issue-link',
|
||||
'application/vnd.gitea.issue-link',
|
||||
]);
|
||||
|
||||
const PR_LINK_MIMES = new Set([
|
||||
'application/vnd.github.pull-request-link',
|
||||
'application/vnd.gitlab.merge-request-link',
|
||||
'application/vnd.gitea.pull-request-link',
|
||||
]);
|
||||
|
||||
type ForgeLinkInfo = { kind: 'issue' | 'pr'; provider: 'github' | 'gitlab' | 'gitea' } | null;
|
||||
|
||||
const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
|
||||
const mime = file.mime;
|
||||
if (!mime || !FORGE_LINK_MIMES.has(mime)) return null;
|
||||
|
||||
if (ISSUE_LINK_MIMES.has(mime)) {
|
||||
if (mime.includes('gitlab')) return { kind: 'issue', provider: 'gitlab' };
|
||||
if (mime.includes('gitea')) return { kind: 'issue', provider: 'gitea' };
|
||||
return { kind: 'issue', provider: 'github' };
|
||||
}
|
||||
if (file.mime === GITHUB_PR_LINK_MIME) {
|
||||
return 'pr';
|
||||
|
||||
if (PR_LINK_MIMES.has(mime)) {
|
||||
if (mime.includes('gitlab')) return { kind: 'pr', provider: 'gitlab' };
|
||||
if (mime.includes('gitea')) return { kind: 'pr', provider: 'gitea' };
|
||||
return { kind: 'pr', provider: 'github' };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const forgeLinkIconName = (info: ForgeLinkInfo): 'github' | 'gitlab' | 'git-branch' | 'git-pull-request' => {
|
||||
if (!info) return 'github';
|
||||
if (info.kind === 'pr') return 'git-pull-request';
|
||||
if (info.provider === 'gitlab') return 'gitlab';
|
||||
if (info.provider === 'gitea') return 'git-branch';
|
||||
return 'github';
|
||||
};
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -590,8 +627,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
};
|
||||
|
||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
const isForgeLink = getForgeLinkInfo(file) !== null;
|
||||
if (isForgeLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
return file.filename.trim();
|
||||
}
|
||||
return extractFilename(file.filename || file.url);
|
||||
@@ -664,11 +701,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const forgeLink = getForgeLinkInfo(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
{githubLinkKind && file.url ? (
|
||||
{forgeLink && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -676,11 +713,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}}
|
||||
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Icon name={forgeLinkIconName(forgeLink)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<div className="overflow-hidden max-w-[220px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
@@ -763,7 +796,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const forgeLink = getForgeLinkInfo(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
@@ -786,7 +819,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
if (forgeLink && file.url) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -801,11 +834,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
)}
|
||||
<Icon name={forgeLinkIconName(forgeLink)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
GITHUB_ISSUE_CONTEXT_PREFIX,
|
||||
GITHUB_PR_CONTEXT_PREFIX,
|
||||
GITLAB_ISSUE_CONTEXT_PREFIX,
|
||||
GITLAB_MR_CONTEXT_PREFIX,
|
||||
GITEA_ISSUE_CONTEXT_PREFIX,
|
||||
GITEA_PR_CONTEXT_PREFIX,
|
||||
startsWithForgeContextPrefix,
|
||||
} from '@/lib/messages/synthetic';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
|
||||
type GitHubIssueContextPayload = {
|
||||
type IssueContextPayload = {
|
||||
issue?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
@@ -19,6 +25,22 @@ type GitHubPrContextPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
type GitLabMrContextPayload = {
|
||||
mr?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type GiteaPrContextPayload = {
|
||||
pr?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isPositiveNumber = (value: unknown): value is number => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
};
|
||||
@@ -41,17 +63,17 @@ const parseSyntheticJsonPayload = <T>(text: string, prefix: string): T | null =>
|
||||
}
|
||||
};
|
||||
|
||||
const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
const issuePayload = parseSyntheticJsonPayload<GitHubIssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
||||
if (issuePayload) {
|
||||
const issue = issuePayload.issue;
|
||||
const buildForgeAttachmentPart = (text: string): Part | null => {
|
||||
// GitHub issues
|
||||
const ghIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
||||
if (ghIssuePayload) {
|
||||
const issue = ghIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.github.issue-link',
|
||||
@@ -60,16 +82,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
const prPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
||||
if (prPayload) {
|
||||
const pr = prPayload.pr;
|
||||
// GitHub PRs
|
||||
const ghPrPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
||||
if (ghPrPayload) {
|
||||
const pr = ghPrPayload.pr;
|
||||
const number = pr?.number;
|
||||
const title = pr?.title;
|
||||
const url = pr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.github.pull-request-link',
|
||||
@@ -78,6 +100,78 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// GitLab issues
|
||||
const glIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITLAB_ISSUE_CONTEXT_PREFIX);
|
||||
if (glIssuePayload) {
|
||||
const issue = glIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitlab.issue-link',
|
||||
filename: `Issue #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// GitLab MRs
|
||||
const glMrPayload = parseSyntheticJsonPayload<GitLabMrContextPayload>(text, GITLAB_MR_CONTEXT_PREFIX);
|
||||
if (glMrPayload) {
|
||||
const mr = glMrPayload.mr;
|
||||
const number = mr?.number;
|
||||
const title = mr?.title;
|
||||
const url = mr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitlab.merge-request-link',
|
||||
filename: `MR !${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// Gitea issues
|
||||
const gtIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITEA_ISSUE_CONTEXT_PREFIX);
|
||||
if (gtIssuePayload) {
|
||||
const issue = gtIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitea.issue-link',
|
||||
filename: `Issue #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// Gitea PRs
|
||||
const gtPrPayload = parseSyntheticJsonPayload<GiteaPrContextPayload>(text, GITEA_PR_CONTEXT_PREFIX);
|
||||
if (gtPrPayload) {
|
||||
const pr = gtPrPayload.pr;
|
||||
const number = pr?.number;
|
||||
const title = pr?.title;
|
||||
const url = pr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitea.pull-request-link',
|
||||
filename: `PR #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -103,8 +197,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
|
||||
const normalizedText = text.trimStart();
|
||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
|| startsWithForgeContextPrefix(normalizedText);
|
||||
})
|
||||
.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -116,7 +209,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = rawPart.synthetic === true;
|
||||
|
||||
if (synthetic) {
|
||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||
const attachmentPart = buildForgeAttachmentPart(text);
|
||||
if (attachmentPart) {
|
||||
return attachmentPart;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ which requests only providers enabled for this panel.
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
|
||||
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
|
||||
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
|
||||
| PR + checks | `usePrVisualSummary` | **read-only** |
|
||||
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
@@ -145,6 +145,10 @@ The panel never calls `startWatching`. PR watching is owned by the background
|
||||
tracker, and its concurrency gate exists because per-consumer PR fetches once
|
||||
saturated the browser's connection pool and stalled startup for ~20s. A panel
|
||||
that started a watch per open session would reintroduce exactly that fan-out.
|
||||
The PR surface can watch a concrete remote while passive readers initially know
|
||||
only the automatic remote key, so the panel reads the freshest entry for the
|
||||
directory and branch across remote keys. This keeps its PR and checks rows in
|
||||
sync with the live PR surface without adding another request owner.
|
||||
|
||||
### Changed files come from git status, not the session
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ import { useSession } from '@/sync/sync-context';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
|
||||
import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive';
|
||||
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { setLinkedIssue } from '@/sync/session-actions';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
@@ -179,6 +181,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const isDraft = sessionId === null && newSessionDraft.open;
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const mcpStatus = useMcpStore(
|
||||
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
||||
@@ -200,7 +207,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
}, [directory, loadSkills]);
|
||||
|
||||
/**
|
||||
* What the project sends along with every message. Read from the server
|
||||
* What this session carries. Read from the server
|
||||
* rather than from the notes panel's store, because this must be right
|
||||
* whether or not that panel has ever been opened.
|
||||
*/
|
||||
@@ -208,44 +215,77 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
|
||||
);
|
||||
|
||||
// Re-read whenever the stores that own pins or memory change, not only when
|
||||
// the directory does. Unpinning is a write those stores make, and a panel
|
||||
// that keeps listing what was just unpinned tells the user it is still going
|
||||
// to the agent when it is not.
|
||||
// Re-read when source content or memory changes, not only when the session does.
|
||||
const contextEntries = useProjectContextStore((state) => state.entries);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const memoryProject = useAgentMemoryStore((state) => state.project);
|
||||
const memoryGlobal = useAgentMemoryStore((state) => state.global);
|
||||
|
||||
const draftProject = React.useMemo(() => {
|
||||
if (!isDraft) return null;
|
||||
const selected = newSessionDraft.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
return selected ?? resolveProjectForSessionDirectory(
|
||||
projects,
|
||||
availableWorktreesByProject,
|
||||
newSessionDraft.directoryOverride ?? directory,
|
||||
);
|
||||
}, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]);
|
||||
|
||||
const draftContextEntry = draftProject
|
||||
? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })]
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDraft || !draftProject) return;
|
||||
void loadProjectContext({ id: draftProject.id, path: draftProject.path });
|
||||
}, [draftProject, isDraft, loadProjectContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(directory).then((summary) => {
|
||||
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
|
||||
if (!cancelled) setKnowledge(summary);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [directory, contextEntries, memoryProject, memoryGlobal]);
|
||||
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, directory, projects]);
|
||||
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
|
||||
if (!isDraft) return knowledge;
|
||||
const pinned = resolveDraftPinnedKnowledge(
|
||||
draftContextEntry?.notes ?? [],
|
||||
draftContextEntry?.plans ?? [],
|
||||
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
|
||||
);
|
||||
return { ...knowledge, ...pinned };
|
||||
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
|
||||
|
||||
// Unpinning from here, like the pinned-messages section: a panel that says
|
||||
// what is attached should be able to detach it, or the user has to go find
|
||||
// the surface that can.
|
||||
const unpinNote = React.useCallback((noteId: string) => {
|
||||
if (projectRef) void setNotePinned(projectRef, noteId, false);
|
||||
}, [projectRef, setNotePinned]);
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('note', noteId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
const unpinPlan = React.useCallback((planId: string) => {
|
||||
if (projectRef) void setPlanPinned(projectRef, planId, false);
|
||||
}, [projectRef, setPlanPinned]);
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('plan', planId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
|
||||
const memoryCount = knowledge.memory.global + knowledge.memory.project;
|
||||
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
|
||||
const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
@@ -281,9 +321,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
|
||||
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
||||
}
|
||||
// Pinned knowledge outranks the ambient counts in the summary: it is
|
||||
// something the user chose for this project, not something that happens to
|
||||
// be installed.
|
||||
// Pinned knowledge outranks ambient counts because the user chose it for this session.
|
||||
if (summaryParts.length === 0 && pinnedCount > 0) {
|
||||
summaryParts.push(pinnedCount === 1
|
||||
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
|
||||
@@ -339,19 +377,18 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
<WorkStatusRow muted label={t('chat.workStatus.linkedIssues.empty')} />
|
||||
) : null}
|
||||
|
||||
{/* Named individually: a count alone would not tell the user which note
|
||||
is riding along with every message they send. */}
|
||||
{/* Named individually: a count alone would not identify this session's context. */}
|
||||
{/* The pin is the control, exactly as in the pinned-messages section
|
||||
above: same icon, same placement, same behaviour. Two pins that look
|
||||
different in one panel would read as two different things. */}
|
||||
{knowledge.notes.map((note) => (
|
||||
{visibleKnowledge.notes.map((note) => (
|
||||
<WorkStatusRow
|
||||
key={note.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -366,14 +403,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{knowledge.plans.map((plan) => (
|
||||
{visibleKnowledge.plans.map((plan) => (
|
||||
<WorkStatusRow
|
||||
key={plan.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary, useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
|
||||
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
|
||||
import { useGitProvider } from '@/lib/gitProvider';
|
||||
@@ -110,11 +110,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
// Read-only: PR watching is owned by the background tracker. Starting a watch
|
||||
// here would multiply GitHub requests per open session, which is exactly the
|
||||
// fan-out the PR-status concurrency gate exists to prevent.
|
||||
const prKey = React.useMemo(
|
||||
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
|
||||
[directory, branch],
|
||||
);
|
||||
const prSummary = usePrVisualSummary(prKey);
|
||||
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
|
||||
|
||||
// GitLab merge requests and Gitea pull requests ride the same shared TTL
|
||||
// cache as the git view and the walkthrough, so every surface that reports
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
describe('resolveDraftPinnedKnowledge', () => {
|
||||
test('shows only notes and plans pinned on this draft', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Attached' }, { id: 'note-b', body: 'Not attached' }],
|
||||
[{ id: 'plan-a', title: 'Attached plan' }, { id: 'plan-b', title: 'Other plan' }],
|
||||
{ notes: ['note-a'], plans: ['plan-a'] },
|
||||
)).toEqual({
|
||||
notes: [{ id: 'note-a', body: 'Attached' }],
|
||||
plans: [{ id: 'plan-a', title: 'Attached plan' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('drops stale ids without borrowing project-wide pins', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Project note' }],
|
||||
[{ id: 'plan-a', title: 'Project plan' }],
|
||||
{ notes: ['missing'], plans: [] },
|
||||
)).toEqual({ notes: [], plans: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SessionKnowledgeSummary, SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
|
||||
type NoteSource = { id: string; body: string };
|
||||
type PlanSource = { id: string; title: string };
|
||||
|
||||
export const resolveDraftPinnedKnowledge = (
|
||||
notes: NoteSource[],
|
||||
plans: PlanSource[],
|
||||
pins: SessionProjectContextPins,
|
||||
): Pick<SessionKnowledgeSummary, 'notes' | 'plans'> => {
|
||||
const noteIds = new Set(pins.notes);
|
||||
const planIds = new Set(pins.plans);
|
||||
return {
|
||||
notes: notes.filter((note) => noteIds.has(note.id)).map(({ id, body }) => ({ id, body })),
|
||||
plans: plans.filter((plan) => planIds.has(plan.id)).map(({ id, title }) => ({ id, title })),
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
|
||||
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import {
|
||||
derivePartsLabel,
|
||||
deriveUserSnippet,
|
||||
@@ -236,16 +237,6 @@ const computeContextBreakdown = (
|
||||
|
||||
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
|
||||
|
||||
const formatMoney = (value: number): string => {
|
||||
if (!Number.isFinite(value) || value <= 0) return new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0);
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
maximumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
||||
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||
import { isFilesystemError } from '@/lib/api/files-errors';
|
||||
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
|
||||
import { isBrowserClientRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -1044,9 +1045,18 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
|
||||
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
|
||||
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
|
||||
const uploadedPaths = droppedFiles.flatMap((file, index) => {
|
||||
const name = getUploadName(file);
|
||||
return outcomes[index] === 'uploaded' && name
|
||||
? [normalizePath(`${directory}/${name}`)]
|
||||
: [];
|
||||
});
|
||||
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
|
||||
|
||||
try {
|
||||
if (uploadedPaths.length > 0) {
|
||||
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
|
||||
}
|
||||
if (uploadedCount > 0 && isCurrentDestination) {
|
||||
await refreshDirectory(directory);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useSessions, useDirectorySync, useSession, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
|
||||
@@ -666,6 +666,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const currentSession = useSession(currentSessionId ?? '');
|
||||
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
@@ -1022,6 +1023,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
percentage={stableContextUsage.percentage}
|
||||
contextLimit={stableContextUsage.contextLimit}
|
||||
outputLimit={stableContextUsage.outputLimit ?? 0}
|
||||
cost={(currentSession?.cost ?? 0) > 0 ? currentSession?.cost : null}
|
||||
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
|
||||
valueClassName="font-semibold leading-none"
|
||||
hideIcon
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ComingSoonMessengersSection } from './ComingSoonMessengersSection';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
@@ -21,8 +20,8 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<ComingSoonMessengersSection />
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={false}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ type PendingAction = 'install' | 'update' | 'setup' | 'remove';
|
||||
type RemoveTarget = ThirdPartyPluginDefinition | null;
|
||||
|
||||
interface ThirdPartyIntegrationsSectionProps {
|
||||
divider?: boolean;
|
||||
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
||||
onOpenPluginManager: () => void;
|
||||
}
|
||||
@@ -46,6 +47,7 @@ const requiresRestart = (result: PluginMutationResult): boolean =>
|
||||
|| result.reloadFailed === true;
|
||||
|
||||
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
|
||||
divider = true,
|
||||
onOpenProviderSetup,
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
@@ -413,6 +415,7 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.thirdParty.title')}
|
||||
info={t('settings.integrations.thirdParty.info')}
|
||||
divider={divider}
|
||||
settingsItem="integrations.third-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
|
||||
@@ -275,8 +275,11 @@ export const DefaultsSettings: React.FC = () => {
|
||||
[walkthroughModelOverride]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
// Both pickers filter by the same authenticated-provider list, and the
|
||||
// walkthrough picker is always visible, so this is always worth fetching.
|
||||
// Both pickers offer the same providers — the walkthrough runs through the
|
||||
// small model — and the walkthrough picker is always visible, so this is
|
||||
// always worth fetching. The server answers with the providers it has a
|
||||
// credential and an endpoint for, including plugin-registered ones that
|
||||
// exist only inside the running OpenCode.
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
normalizeAuthType,
|
||||
@@ -15,6 +15,14 @@ describe('ProvidersPage available provider loading', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProvidersPage provider authentication', () => {
|
||||
test('does not require credentials for a custom provider defined in config', () => {
|
||||
expect(requiresProviderAuth(true, false, true)).toBe(false);
|
||||
expect(requiresProviderAuth(true, false, false)).toBe(true);
|
||||
expect(requiresProviderAuth(true, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider auth method helpers', () => {
|
||||
test('normalizeAuthType recognizes oauth and api labels', () => {
|
||||
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ModelMetadata } from '@/types';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
parseAuthPayload,
|
||||
@@ -324,7 +324,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
|
||||
if (!hasCreds) {
|
||||
const isCustomProvider = Boolean(provider && isConfigDefinedCustomProvider(provider, sources));
|
||||
if (requiresProviderAuth(true, hasCreds, isCustomProvider)) {
|
||||
setShowAuthPanel(true);
|
||||
}
|
||||
}, [selectedProviderId, providerSources, providers]);
|
||||
@@ -773,8 +774,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
|
||||
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
|
||||
const authStatusIncomplete = requiresProviderAuth(
|
||||
sourcesLoaded,
|
||||
hasCredentials,
|
||||
isEditableCustomProvider,
|
||||
);
|
||||
const showModelsSection = providerModels.length > 0 && !authStatusIncomplete;
|
||||
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
||||
? t('settings.providers.page.auth.useReconnectHint')
|
||||
: t('settings.providers.page.auth.incompleteHint');
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
|
||||
|
||||
export const requiresProviderAuth = (
|
||||
sourcesLoaded: boolean,
|
||||
hasCredentials: boolean,
|
||||
isConfigDefinedCustomProvider: boolean,
|
||||
): boolean => sourcesLoaded && !hasCredentials && !isConfigDefinedCustomProvider;
|
||||
|
||||
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
directoryOverride?: string | null;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
// Build selection with clawdhub metadata if present
|
||||
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
|
||||
skillDir: request.skillDir,
|
||||
};
|
||||
if (item?.clawdhub) {
|
||||
selection.clawdhub = {
|
||||
slug: item.clawdhub.slug,
|
||||
version: item.clawdhub.version,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await installSkills({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: [selection],
|
||||
selections: [{ skillDir: request.skillDir }],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,24 +14,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
|
||||
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
|
||||
showModeTabs?: boolean;
|
||||
}
|
||||
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
const getRepoUrl = (source: string): string | null => {
|
||||
const trimmed = source.trim();
|
||||
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return `https://github.com/${trimmed}`;
|
||||
};
|
||||
|
||||
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
|
||||
const repoUrl = getRepoUrl(item.repoSource);
|
||||
if (!repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
|
||||
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
|
||||
};
|
||||
|
||||
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
|
||||
|
||||
const formatStars = (stars: number): string => {
|
||||
const locale = getCurrentIntlLocale();
|
||||
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
|
||||
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
|
||||
}
|
||||
return cachedStarsFormatter.formatter.format(stars);
|
||||
};
|
||||
|
||||
type RelativeTimeKey =
|
||||
| 'common.relative.justNow'
|
||||
| 'common.relative.minutesAgoShort'
|
||||
| 'common.relative.hoursAgoShort'
|
||||
| 'common.relative.daysAgoShort'
|
||||
| 'common.relative.weeksAgoShort'
|
||||
| 'common.relative.yearsAgoShort';
|
||||
|
||||
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
|
||||
const timestamp = Date.parse(isoDate);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60_000) {
|
||||
return { key: 'common.relative.justNow', count: 0 };
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return { key: 'common.relative.minutesAgoShort', count: minutes };
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return { key: 'common.relative.hoursAgoShort', count: hours };
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
return { key: 'common.relative.daysAgoShort', count: days };
|
||||
}
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 52) {
|
||||
return { key: 'common.relative.weeksAgoShort', count: weeks };
|
||||
}
|
||||
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
|
||||
};
|
||||
|
||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
try {
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const SourceCard: React.FC<{
|
||||
source: SkillsCatalogSource;
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
skillsCount: number | null;
|
||||
onSelect: () => void;
|
||||
t: ReturnType<typeof useI18n>['t'];
|
||||
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
|
||||
const stars = source.stars ?? null;
|
||||
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive
|
||||
? 'border-primary'
|
||||
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
|
||||
{isLoading ? (
|
||||
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
skillsCount !== null && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
|
||||
<span className="flex items-center gap-3 mt-1">
|
||||
{stars !== null && (
|
||||
<span
|
||||
className="typography-micro text-muted-foreground flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
|
||||
>
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{formatStars(stars)}
|
||||
</span>
|
||||
)}
|
||||
{updated && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{updated.key === 'common.relative.justNow'
|
||||
? t(updated.key)
|
||||
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
loadSource,
|
||||
loadMoreClawdHub,
|
||||
isLoadingCatalog,
|
||||
isLoadingSource,
|
||||
isLoadingMore,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore(useShallow((s) => ({
|
||||
sources: s.sources,
|
||||
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource: s.setSelectedSource,
|
||||
loadCatalog: s.loadCatalog,
|
||||
loadSource: s.loadSource,
|
||||
loadMoreClawdHub: s.loadMoreClawdHub,
|
||||
isLoadingCatalog: s.isLoadingCatalog,
|
||||
isLoadingSource: s.isLoadingSource,
|
||||
isLoadingMore: s.isLoadingMore,
|
||||
loadedSourceIds: s.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
|
||||
lastCatalogError: s.lastCatalogError,
|
||||
})));
|
||||
|
||||
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
||||
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
||||
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
// Load every source in the background so global search covers all of them.
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
|
||||
if (unloaded.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!loadedSourceIds[selectedSourceId]) {
|
||||
void loadSource(selectedSourceId);
|
||||
let cancelled = false;
|
||||
const loadRest = async () => {
|
||||
for (const src of unloaded) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
await loadSource(src.id);
|
||||
}
|
||||
};
|
||||
void loadRest();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, loadedSourceIds, loadSource]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
|
||||
return;
|
||||
}
|
||||
void loadSource(selectedSourceId);
|
||||
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [itemsBySource, selectedSourceId]);
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
const matches = (item: SkillsCatalogItem) =>
|
||||
item.skillName.toLowerCase().includes(q)
|
||||
|| (item.description || '').toLowerCase().includes(q)
|
||||
|| (item.frontmatterName || '').toLowerCase().includes(q);
|
||||
|
||||
if (isSearching) {
|
||||
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
|
||||
}
|
||||
if (!selectedSourceId) {
|
||||
return [];
|
||||
}
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
|
||||
|
||||
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||
|
||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
||||
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
|
||||
const hasMoreClawdHub = Boolean(
|
||||
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
|
||||
);
|
||||
|
||||
const removeSelectedCatalog = async () => {
|
||||
if (!selectedSourceId || !isCustomSource) {
|
||||
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
};
|
||||
|
||||
const listTitle = isSearching
|
||||
? t('settings.skills.catalog.page.list.searchTitle')
|
||||
: (selectedSource?.label ?? '');
|
||||
|
||||
// The selected source has no items yet and a load is in flight — show the
|
||||
// loading state instead of a stale list from the previously selected source.
|
||||
const isSelectedSourceLoading = !isSearching
|
||||
&& selectedSourceId !== null
|
||||
&& !loadedSourceIds[selectedSourceId]
|
||||
&& (isLoadingSource || isLoadingCatalog);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="typography-meta text-muted-foreground mb-4">
|
||||
{t('settings.skills.catalog.page.subtitle')}
|
||||
</p>
|
||||
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="mb-5">
|
||||
<div className="relative max-w-md">
|
||||
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
|
||||
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('settings.skills.catalog.page.search.clear')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.skills.catalog.page.section.sourceRepository')}
|
||||
title={t('settings.skills.catalog.page.section.sources')}
|
||||
divider={false}
|
||||
settingsItem="skills.catalog.source"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<Select
|
||||
value={selectedSourceId || ''}
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
|
||||
{selectedSource?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
<SelectItem key={src.id} value={src.id}>
|
||||
{src.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
source={src}
|
||||
isActive={src.id === selectedSourceId}
|
||||
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
|
||||
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
|
||||
onSelect={() => setSelectedSource(src.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
{isCustomSource && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
data-settings-item="skills.catalog.add-catalog"
|
||||
size="xs"
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="py-1.5">
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="h-7 pl-8 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground mt-1 block">
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
<button
|
||||
type="button"
|
||||
data-settings-item="skills.catalog.add-catalog"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors"
|
||||
>
|
||||
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="typography-ui-label text-muted-foreground block">
|
||||
{t('settings.skills.catalog.page.source.addOwnTitle')}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
|
||||
{t('settings.skills.catalog.page.source.addOwnDescription')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{lastCatalogError && (
|
||||
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
)}
|
||||
|
||||
<SettingsSection>
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
|
||||
{listTitle}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 shrink-0">
|
||||
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId && !isSearching) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
|
||||
</Button>
|
||||
{isCustomSource && !isSearching && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
const skillUrl = getSkillUrl(item);
|
||||
|
||||
return (
|
||||
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
|
||||
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
|
||||
{item.clawdhub && (
|
||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
||||
{item.clawdhub.owner && (
|
||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="download" className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
|
||||
{skillUrl ? (
|
||||
<a
|
||||
href={skillUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono hover:underline truncate inline-flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="github" className="h-3 w-3 shrink-0" />
|
||||
{item.repoSource}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono truncate">{item.repoSource}</span>
|
||||
)}
|
||||
{item.skillDir && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{item.skillDir}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
|
||||
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{skillUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{installed ? (
|
||||
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll })
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -60,14 +60,13 @@ Leaving the section or the project closes it, so its editor never sits over a
|
||||
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||
still pass `onOpenPlan` and keep theirs.
|
||||
|
||||
## Pins are project state, not a message attachment
|
||||
## Pins belong to one session
|
||||
|
||||
Pinning a note or plan writes to the project, not to the session, so it holds
|
||||
across every session in that project until it is unpinned. The composer once
|
||||
carried a chip for it, from when pinned context was a one-shot attachment to the
|
||||
next message; standing state shown permanently above the input reads as
|
||||
something being attached to what you are typing, which it is not. What is
|
||||
attached, and the control to detach it, live in the work status panel instead.
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
session metadata. Other sessions in the project do not inherit it. A pin made
|
||||
while a new-session draft is open lives on that draft and transfers only to the
|
||||
session created by its first message. Work status lists and detaches draft pins
|
||||
before that first message, then reads them from the created session metadata.
|
||||
|
||||
## Memory is not a fifth kind of note
|
||||
|
||||
@@ -211,10 +210,8 @@ matched the old project would silently hide everything in the new one.
|
||||
|
||||
## Pinned context
|
||||
|
||||
The pin toggle on a note or plan marks it as standing context for the agent.
|
||||
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
|
||||
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
|
||||
what is riding along.
|
||||
The pin toggle on a note or plan attaches it to the current session or draft.
|
||||
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
|
||||
*/
|
||||
const NoteRow: React.FC<{
|
||||
note: ProjectNote;
|
||||
pinned: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onSaveBody: (body: string) => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = React.useState(note.body);
|
||||
const lastSavedRef = React.useRef(note.body);
|
||||
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
|
||||
onClick={onTogglePinned}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={note.pinned
|
||||
title={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
{/* Filled means pinned, outline means "pin this" — the same
|
||||
language the work status panel uses. */}
|
||||
<Icon name={note.pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
|
||||
notes: ProjectNote[];
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
}> = ({ projectRef, notes, disabled, query }) => {
|
||||
pinnedNoteIds: ReadonlySet<string>;
|
||||
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const [composerText, setComposerText] = React.useState('');
|
||||
// One at a time on purpose: notes can run to 3000 characters each, and
|
||||
@@ -173,7 +176,6 @@ export const NotesSection: React.FC<{
|
||||
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
|
||||
const createNote = useProjectContextStore((state) => state.createNote);
|
||||
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
||||
|
||||
const visibleNotes = React.useMemo(() => {
|
||||
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (noteId: string, pinned: boolean) => {
|
||||
const ok = await setNotePinned(projectRef, noteId, pinned);
|
||||
const ok = await onTogglePinned(noteId, pinned);
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
},
|
||||
[projectRef, reportFailure, setNotePinned, t]
|
||||
[onTogglePinned, reportFailure, t]
|
||||
);
|
||||
|
||||
const handleSaveBody = React.useCallback(
|
||||
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
|
||||
<NoteRow
|
||||
key={note.id}
|
||||
note={note}
|
||||
pinned={pinnedNoteIds.has(note.id)}
|
||||
expanded={expandedNoteId === note.id}
|
||||
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
||||
onSaveBody={(body) => handleSaveBody(note.id, body)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
|
||||
query: string;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan }) => {
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||
const createPlan = useProjectContextStore((state) => state.createPlan);
|
||||
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (planId: string, pinned: boolean) => {
|
||||
const ok = await setPlanPinned(projectRef, planId, pinned);
|
||||
const ok = await onTogglePinned(planId, pinned);
|
||||
if (!ok) {
|
||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
||||
}
|
||||
},
|
||||
[projectRef, setPlanPinned, t]
|
||||
[onTogglePinned, projectRef, t]
|
||||
);
|
||||
|
||||
const visiblePlans = React.useMemo(() => {
|
||||
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
|
||||
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={plan.pinned}
|
||||
aria-label={plan.pinned
|
||||
aria-pressed={pinnedPlanIds.has(plan.id)}
|
||||
aria-label={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={plan.pinned
|
||||
title={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
|
||||
import { PlansSection } from './PlansSection';
|
||||
import { TodosSection } from './TodosSection';
|
||||
import { useProjectTodoSend } from './useProjectTodoSend';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
|
||||
@@ -85,6 +87,43 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const saveTodos = useProjectContextStore((state) => state.saveTodos);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (newSessionDraft.open) {
|
||||
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
|
||||
if (!cancelled) {
|
||||
setSessionPins({
|
||||
notes: summary.notes.map((note) => note.id),
|
||||
plans: summary.plans.map((plan) => plan.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
|
||||
|
||||
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
|
||||
if (newSessionDraft.open) {
|
||||
setDraftProjectContextPin(kind, id, pinned);
|
||||
return true;
|
||||
}
|
||||
if (!currentSessionId || !currentSessionDirectory) return false;
|
||||
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
|
||||
if (!next) return false;
|
||||
setSessionPins(next);
|
||||
return true;
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
|
||||
|
||||
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
|
||||
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
|
||||
|
||||
// The whole feature is one switch: with memory off there is nothing for the
|
||||
// agent to manage, so showing the user what is stored would be pointless.
|
||||
@@ -387,6 +426,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
notes={contextEntry.notes}
|
||||
disabled={isLoading}
|
||||
query={query}
|
||||
pinnedNoteIds={pinnedNoteIds}
|
||||
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
projectRef={projectRef}
|
||||
plans={contextEntry.plans}
|
||||
query={query}
|
||||
pinnedPlanIds={pinnedPlanIds}
|
||||
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
|
||||
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
||||
// desktop panel the plan opens here, in place of the list.
|
||||
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
||||
|
||||
@@ -1185,7 +1185,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</div>
|
||||
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1208,7 +1208,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
{group.directory && !group.isMain && group.worktree ? (
|
||||
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1232,7 +1232,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
{group.directory ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -224,7 +224,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
icon inset inside the 24px buttons so the first glyph lines up
|
||||
with the New-session icon above (16px from the sidebar edge). */}
|
||||
<div className="ml-[3px] flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,7 +98,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,7 +112,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,7 +127,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,7 +143,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -158,7 +158,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,7 +180,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -312,7 +312,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
showCreateButtons ? 'right-7' : 'right-0.5',
|
||||
)}>
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -368,7 +368,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
|
||||
{showCreateButtons && onNewSession ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
||||
|
||||
interface ContextUsageDisplayProps {
|
||||
@@ -12,6 +13,7 @@ interface ContextUsageDisplayProps {
|
||||
colorPercentage?: number;
|
||||
contextLimit: number;
|
||||
outputLimit?: number;
|
||||
cost?: number | null;
|
||||
size?: 'default' | 'compact';
|
||||
isMobile?: boolean;
|
||||
hideIcon?: boolean;
|
||||
@@ -29,6 +31,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
colorPercentage,
|
||||
contextLimit,
|
||||
outputLimit,
|
||||
cost = null,
|
||||
size = 'default',
|
||||
isMobile = false,
|
||||
hideIcon = false,
|
||||
@@ -73,10 +76,13 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
|
||||
|
||||
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
||||
const normalizedCost = cost ?? 0;
|
||||
const hasCost = normalizedCost > 0 && Number.isFinite(normalizedCost);
|
||||
const tooltipLines = [
|
||||
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
||||
t('contextUsage.tooltip.contextLimit', { tokens: formatTokens(contextLimit) }),
|
||||
t('contextUsage.tooltip.outputLimit', { tokens: formatTokens(safeOutputLimit) }),
|
||||
...(hasCost ? [t('contextUsage.tooltip.cost', { cost: formatMoney(normalizedCost) })] : []),
|
||||
];
|
||||
|
||||
const isInteractive = !isMobile && typeof onClick === 'function';
|
||||
@@ -183,6 +189,12 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.outputLimit')}</span>
|
||||
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
|
||||
</div>
|
||||
{hasCost ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.cost')}</span>
|
||||
<span className="typography-meta text-foreground font-medium">{formatMoney(normalizedCost)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between items-center pt-1 border-t border-border/40">
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.usage')}</span>
|
||||
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
|
||||
|
||||
@@ -111,7 +111,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<BaseDialog.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<Icon name="close"/>
|
||||
<span className="sr-only">{t('dialog.common.actions.close')}</span>
|
||||
|
||||
@@ -48,8 +48,9 @@ import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile,
|
||||
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||
import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation';
|
||||
import { DiagramEditor } from '@/components/diagram';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
@@ -920,6 +921,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||
const activeFileLoadIdRef = React.useRef(0);
|
||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||
const [fileContentRevision, setFileContentRevision] = React.useState(0);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||
const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false);
|
||||
@@ -2046,13 +2048,33 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
loadingFilePathRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [loadSelectedFile, loadedFilePath, selectedFile]);
|
||||
}, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]);
|
||||
|
||||
// Sync isDirty to a ref so the polling interval can read the latest value
|
||||
// without isDirty in its dependency array (avoids interval restart on every edit/save).
|
||||
const isDirtyRef = React.useRef(isDirty);
|
||||
isDirtyRef.current = isDirty;
|
||||
|
||||
React.useEffect(() => subscribeToFileContentInvalidation(({ runtimeKey, paths }) => {
|
||||
const selectedPath = selectedFile?.path;
|
||||
if (
|
||||
runtimeKey !== getRuntimeKey()
|
||||
|| !selectedPath
|
||||
|| isDirtyRef.current
|
||||
|| !paths.includes(normalizePath(selectedPath))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeFileLoadIdRef.current += 1;
|
||||
loadingFilePathRef.current = null;
|
||||
lastLoadedFileStatRef.current = null;
|
||||
setDesktopImageSrc('');
|
||||
setFileError(null);
|
||||
setLoadedFilePath(null);
|
||||
setFileContentRevision((revision) => revision + 1);
|
||||
}), [selectedFile?.path]);
|
||||
|
||||
// Poll open file for external changes.
|
||||
// When a change is detected, reset loadedFilePath so the effect above
|
||||
// triggers a single reload — no double-load.
|
||||
@@ -3006,11 +3028,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
|
||||
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}`
|
||||
: '';
|
||||
|
||||
const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
|
||||
? selectedFile.path
|
||||
? `${selectedFile.path}|${fileContentRevision}`
|
||||
: '';
|
||||
|
||||
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
|
||||
@@ -3113,7 +3135,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
|
||||
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
||||
|
||||
|
||||
@@ -2072,7 +2072,7 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
|
||||
type SkillsCatalogSourceId = string;
|
||||
|
||||
type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
||||
type SkillsCatalogSourceType = 'github';
|
||||
|
||||
export interface SkillsCatalogSource {
|
||||
id: SkillsCatalogSourceId;
|
||||
@@ -2081,6 +2081,10 @@ export interface SkillsCatalogSource {
|
||||
source: string;
|
||||
defaultSubpath?: string;
|
||||
sourceType?: SkillsCatalogSourceType;
|
||||
/** GitHub repository star count (null when unavailable) */
|
||||
stars?: number | null;
|
||||
/** GitHub repository last-push timestamp, ISO (null when unavailable) */
|
||||
repoUpdatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface SkillsCatalogItemInstalledBadge {
|
||||
@@ -2089,18 +2093,6 @@ interface SkillsCatalogItemInstalledBadge {
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
interface ClawdHubSkillMetadata {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
versionsCount?: number;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItem {
|
||||
sourceId: SkillsCatalogSourceId;
|
||||
repoSource: string;
|
||||
@@ -2113,22 +2105,18 @@ export interface SkillsCatalogItem {
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
installed?: SkillsCatalogItemInstalledBadge;
|
||||
/** ClawdHub-specific metadata (present only for ClawdHub sources) */
|
||||
clawdhub?: ClawdHubSkillMetadata;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogResponse {
|
||||
ok: boolean;
|
||||
sources?: SkillsCatalogSource[];
|
||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
export interface SkillsCatalogSourceResponse {
|
||||
ok: boolean;
|
||||
items?: SkillsCatalogItem[];
|
||||
nextCursor?: string | null;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
@@ -2153,11 +2141,6 @@ export interface SkillsRepoScanResponse {
|
||||
|
||||
interface SkillsInstallSelection {
|
||||
skillDir: string;
|
||||
/** ClawdHub-specific metadata for installation */
|
||||
clawdhub?: {
|
||||
slug: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillsInstallRequest {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
notifyFileContentInvalidated,
|
||||
subscribeToFileContentInvalidation,
|
||||
} from './fileContentInvalidation';
|
||||
|
||||
describe('fileContentInvalidation', () => {
|
||||
test('publishes normalized paths within the captured runtime', () => {
|
||||
const received: Array<{ runtimeKey: string; paths: readonly string[] }> = [];
|
||||
const unsubscribe = subscribeToFileContentInvalidation((invalidation) => {
|
||||
received.push(invalidation);
|
||||
});
|
||||
|
||||
notifyFileContentInvalidated({
|
||||
runtimeKey: ' runtime-a ',
|
||||
paths: [' /repo/a.txt ', '/repo/a.txt', '', '/repo/b.txt'],
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toEqual([{
|
||||
runtimeKey: 'runtime-a',
|
||||
paths: ['/repo/a.txt', '/repo/b.txt'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('stops publishing after unsubscribe', () => {
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToFileContentInvalidation(() => {
|
||||
calls += 1;
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
notifyFileContentInvalidated({ runtimeKey: 'runtime-a', paths: ['/repo/a.txt'] });
|
||||
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
type FileContentInvalidation = {
|
||||
runtimeKey: string;
|
||||
paths: readonly string[];
|
||||
};
|
||||
|
||||
type FileContentInvalidationListener = (invalidation: FileContentInvalidation) => void;
|
||||
|
||||
const listeners = new Set<FileContentInvalidationListener>();
|
||||
|
||||
export const notifyFileContentInvalidated = (invalidation: FileContentInvalidation): void => {
|
||||
const runtimeKey = invalidation.runtimeKey.trim();
|
||||
const paths = Array.from(new Set(invalidation.paths.map((path) => path.trim()).filter(Boolean)));
|
||||
if (!runtimeKey || paths.length === 0) return;
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener({ runtimeKey, paths });
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeToFileContentInvalidation = (
|
||||
listener: FileContentInvalidationListener,
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
@@ -854,16 +854,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuell',
|
||||
'settings.skills.catalog.page.mode.external': 'Extern',
|
||||
'settings.skills.catalog.page.title': 'Fähigkeitskatalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.',
|
||||
'settings.skills.catalog.page.section.sources': 'Quellen',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…',
|
||||
'settings.skills.catalog.page.search.clear': 'Suche löschen',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Sterne: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...',
|
||||
'settings.skills.catalog.page.loading.more': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden',
|
||||
@@ -871,7 +881,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installiert ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unbekannt',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'von',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1383,10 +1383,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Verwendete Tokens',
|
||||
'contextUsage.mobile.contextLimit': 'Kontextlimit',
|
||||
'contextUsage.mobile.outputLimit': 'Ausgabelimit',
|
||||
'contextUsage.mobile.cost': 'Kosten',
|
||||
'contextUsage.mobile.usage': 'Nutzung',
|
||||
'contextUsage.tooltip.usedTokens': 'Verwendete Tokens: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Kontextlimit: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Ausgabelimit: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Kosten: {cost}',
|
||||
'contextSidebar.session.untitled': 'Unbenannte Sitzung',
|
||||
'contextSidebar.empty.openSession': 'Öffnen Sie eine Sitzung, um den Kontext zu prüfen.',
|
||||
'contextSidebar.section.context': 'Kontext',
|
||||
@@ -2163,6 +2165,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Zurückgesetzt auf {preview}',
|
||||
'chat.revert.toast.redo': 'Wiederholt',
|
||||
'chat.revert.toast.restored': 'Alle Nachrichten wiederhergestellt',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Chat unterbrochen',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode wurde neu gestartet, während noch eine Antwort lief. Senden Sie eine Nachricht, um fortzufahren.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Sitzung öffnen',
|
||||
'chat.errorBoundary.title': 'Chat-Fehler',
|
||||
'chat.errorBoundary.description': 'Die Chat-Oberfläche hat einen Fehler festgestellt. Dies könnte auf ein vorübergehendes Netzwerkproblem oder beschädigte Nachrichtendaten zurückzuführen sein.',
|
||||
'chat.errorBoundary.sessionLabel': 'Sitzung',
|
||||
|
||||
@@ -906,16 +906,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manual',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': 'Skills Catalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.',
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Clear search',
|
||||
'settings.skills.catalog.page.source.skillsCount': '{count} skills',
|
||||
'settings.skills.catalog.page.source.stars': '{count} stars',
|
||||
'settings.skills.catalog.page.source.updated': 'Updated {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Search results',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Source Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Loading...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Loading skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Loading...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) found',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found',
|
||||
@@ -923,7 +933,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installed ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unknown',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'by',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1635,10 +1635,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Used tokens',
|
||||
'contextUsage.mobile.contextLimit': 'Context limit',
|
||||
'contextUsage.mobile.outputLimit': 'Output limit',
|
||||
'contextUsage.mobile.cost': 'Cost',
|
||||
'contextUsage.mobile.usage': 'Usage',
|
||||
'contextUsage.tooltip.usedTokens': 'Used tokens: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Context limit: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Output limit: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Cost: {cost}',
|
||||
'contextSidebar.session.untitled': 'Untitled Session',
|
||||
'contextSidebar.empty.openSession': 'Open a session to inspect context.',
|
||||
'contextSidebar.section.context': 'Context',
|
||||
@@ -2421,6 +2423,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Reverted to {preview}',
|
||||
'chat.revert.toast.redo': 'Redone',
|
||||
'chat.revert.toast.restored': 'Restored all messages',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Chat interrupted',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode restarted while a response was still running. Send a message to continue.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Open session',
|
||||
'chat.errorBoundary.title': 'Chat Error',
|
||||
'chat.errorBoundary.description': 'The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.',
|
||||
'chat.errorBoundary.sessionLabel': 'Session',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fuentes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Borrar búsqueda',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrellas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Actualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Cargando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Cargando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Cargando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconocido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Claves de paso",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
"contextUsage.mobile.contextLimit": "Límite de contexto",
|
||||
"contextUsage.mobile.outputLimit": "Límite de salida",
|
||||
"contextUsage.mobile.cost": "Costo",
|
||||
"contextUsage.mobile.usage": "Uso",
|
||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Límite de contexto: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Límite de salida: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Costo: {cost}",
|
||||
"contextSidebar.session.untitled": "Sesión sin título",
|
||||
"contextSidebar.empty.openSession": "Abrir una sesión para inspeccionar el contexto.",
|
||||
"contextSidebar.section.context": "Contexto",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Revertido a {preview}",
|
||||
"chat.revert.toast.redo": "Rehecho",
|
||||
"chat.revert.toast.restored": "Todos los mensajes restaurados",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Conversación interrumpida",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "OpenCode se reinició mientras aún se estaba generando una respuesta. Envía un mensaje para continuar.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Abrir sesión",
|
||||
"chat.errorBoundary.title": "Error en la conversación",
|
||||
"chat.errorBoundary.description": "La interfaz de la conversación encontró un error. Esto podría deberse a un problema de red temporal o a datos de mensaje corruptos.",
|
||||
"chat.errorBoundary.sessionLabel": "Sesión",
|
||||
|
||||
@@ -792,16 +792,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuel',
|
||||
'settings.skills.catalog.page.mode.external': 'Externe',
|
||||
'settings.skills.catalog.page.title': 'Catalogue de skills',
|
||||
'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.",
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Effacer la recherche',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Étoiles : {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Mis à jour {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills",
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Chargement...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Chargement des skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Chargement...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé',
|
||||
@@ -809,7 +819,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installé ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'inconnu',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'par',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
|
||||
'settings.openchamber.passkeys.title': 'Mots-clés',
|
||||
|
||||
@@ -1311,10 +1311,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Jetons utilisés',
|
||||
'contextUsage.mobile.contextLimit': 'Limite de contexte',
|
||||
'contextUsage.mobile.outputLimit': 'Limite de sortie',
|
||||
'contextUsage.mobile.cost': 'Coût',
|
||||
'contextUsage.mobile.usage': 'Usage',
|
||||
'contextUsage.tooltip.usedTokens': 'Jetons utilisés : {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Limite de contexte : {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Limite de sortie : {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Coût : {cost}',
|
||||
'contextSidebar.session.untitled': 'Session sans titre',
|
||||
'contextSidebar.empty.openSession': 'Ouvrez une session pour inspecter le contexte.',
|
||||
'contextSidebar.section.context': 'Contexte',
|
||||
@@ -2074,6 +2076,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Revenu à {preview}',
|
||||
'chat.revert.toast.redo': 'Refait',
|
||||
'chat.revert.toast.restored': 'Restauré tous les messages',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Discussion interrompue',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode a redémarré alors qu’une réponse était encore en cours. Envoyez un message pour continuer.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Ouvrir la session',
|
||||
'chat.errorBoundary.title': 'Erreur de discussion',
|
||||
'chat.errorBoundary.description': 'L\'interface de discussion a rencontré une erreur. Cela peut être dû à un problème de réseau temporaire ou à des données de message corrompues.',
|
||||
'chat.errorBoundary.sessionLabel': 'Session',
|
||||
|
||||
@@ -907,16 +907,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'スキルカタログ',
|
||||
'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。',
|
||||
'settings.skills.catalog.page.section.sources': 'ソース',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…',
|
||||
'settings.skills.catalog.page.search.clear': '検索をクリア',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'スター: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示',
|
||||
'settings.skills.catalog.page.list.searchTitle': '検索結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '更新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む',
|
||||
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
|
||||
'settings.skills.catalog.page.loading.more': '読み込み中...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません',
|
||||
@@ -924,7 +934,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
|
||||
'settings.skills.catalog.page.badge.unknown': '不明',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '提供',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
|
||||
'settings.openchamber.passkeys.title': 'パスキー',
|
||||
|
||||
@@ -1632,10 +1632,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '使用トークン',
|
||||
'contextUsage.mobile.contextLimit': 'コンテキスト制限',
|
||||
'contextUsage.mobile.outputLimit': '出力制限',
|
||||
'contextUsage.mobile.cost': 'コスト',
|
||||
'contextUsage.mobile.usage': '使用量',
|
||||
'contextUsage.tooltip.usedTokens': '使用トークン: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'コンテキスト制限: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '出力制限: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'コスト: {cost}',
|
||||
'contextSidebar.session.untitled': '無題のセッション',
|
||||
'contextSidebar.empty.openSession': 'セッションを開いてコンテキストを確認します。',
|
||||
'contextSidebar.section.context': 'コンテキスト',
|
||||
@@ -2418,6 +2420,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '{preview}に元に戻しました',
|
||||
'chat.revert.toast.redo': 'やり直しました',
|
||||
'chat.revert.toast.restored': 'すべてのメッセージを復元しました',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'チャットが中断されました',
|
||||
'chat.toast.opencodeRestartInterrupted.description': '応答の生成中に OpenCode が再起動しました。続行するにはメッセージを送信してください。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'セッションを開く',
|
||||
'chat.errorBoundary.title': 'チャットエラー',
|
||||
'chat.errorBoundary.description': 'チャットインターフェースでエラーが発生しました。一時的なネットワーク問題または破損したメッセージデータが原因の可能性があります。',
|
||||
'chat.errorBoundary.sessionLabel': 'セッション',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '수동',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': '스킬 카탈로그',
|
||||
'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.',
|
||||
'settings.skills.catalog.page.section.sources': '소스',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…',
|
||||
'settings.skills.catalog.page.search.clear': '검색 지우기',
|
||||
'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개',
|
||||
'settings.skills.catalog.page.source.stars': '스타: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '업데이트: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기',
|
||||
'settings.skills.catalog.page.list.searchTitle': '검색 결과',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기',
|
||||
'settings.skills.catalog.page.loading.catalog': '로딩 중...',
|
||||
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
|
||||
'settings.skills.catalog.page.loading.more': '로딩 중...',
|
||||
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다',
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '설치됨({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
|
||||
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '작성자',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1638,10 +1638,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
||||
'contextUsage.mobile.contextLimit': '컨텍스트 한도',
|
||||
'contextUsage.mobile.outputLimit': '출력 한도',
|
||||
'contextUsage.mobile.cost': '비용',
|
||||
'contextUsage.mobile.usage': '사용량',
|
||||
'contextUsage.tooltip.usedTokens': '사용됨 토큰: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '컨텍스트 한도: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '출력 한도: {tokens}',
|
||||
'contextUsage.tooltip.cost': '비용: {cost}',
|
||||
'contextSidebar.session.untitled': '제목 없는 세션',
|
||||
'contextSidebar.empty.openSession': '컨텍스트를 볼 세션을 여세요.',
|
||||
'contextSidebar.section.context': '컨텍스트',
|
||||
@@ -2424,6 +2426,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '{preview}(으)로 되돌림',
|
||||
'chat.revert.toast.redo': '다시 실행',
|
||||
'chat.revert.toast.restored': '모든 메시지 복원됨',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '채팅이 중단되었습니다',
|
||||
'chat.toast.opencodeRestartInterrupted.description': '응답이 진행 중인 동안 OpenCode가 다시 시작되었습니다. 계속하려면 메시지를 보내세요.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '세션 열기',
|
||||
'chat.errorBoundary.title': '채팅 오류',
|
||||
'chat.errorBoundary.description': '채팅 인터페이스에서 오류가 발생했습니다. 일시적인 네트워크 이슈 또는 손상된 메시지 데이터 때문일 수 있습니다.',
|
||||
'chat.errorBoundary.sessionLabel': '세션',
|
||||
|
||||
@@ -1918,21 +1918,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności',
|
||||
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
|
||||
'settings.skills.catalog.page.badge.unknown': 'nieznane',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'autor:',
|
||||
'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
|
||||
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.more': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...',
|
||||
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
|
||||
'settings.skills.catalog.page.mode.manual': 'Ręczny',
|
||||
@@ -1940,6 +1937,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
|
||||
'settings.skills.catalog.page.title': 'Katalog umiejętności',
|
||||
'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.',
|
||||
'settings.skills.catalog.page.section.sources': 'Źródła',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…',
|
||||
'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania',
|
||||
'settings.skills.catalog.shared.actions.install': 'Zainstaluj',
|
||||
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
|
||||
'settings.skills.catalog.shared.actions.scan': 'Skanuj',
|
||||
|
||||
@@ -812,6 +812,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': 'Cofnięte do {preview}',
|
||||
'chat.revert.toast.redo': 'Ponowione',
|
||||
'chat.revert.toast.restored': 'Przywrócono wszystkie wiadomości',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Czat został przerwany',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode uruchomił się ponownie podczas generowania odpowiedzi. Wyślij wiadomość, aby kontynuować.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Otwórz sesję',
|
||||
'chat.errorBoundary.title': 'Błąd Czatu',
|
||||
'chat.errorBoundary.description': 'Interfejs czatu napotkał błąd. Może to być spowodowane tymczasowym problemem sieciowym lub uszkodzonymi danymi wiadomości.',
|
||||
'chat.errorBoundary.sessionLabel': 'Sesja',
|
||||
@@ -1789,11 +1792,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.aria.label': 'Użycie kontekstu',
|
||||
'contextUsage.mobile.contextLimit': 'Limit kontekstu',
|
||||
'contextUsage.mobile.outputLimit': 'Limit wyjścia',
|
||||
'contextUsage.mobile.cost': 'Koszt',
|
||||
'contextUsage.mobile.title': 'Użycie kontekstu',
|
||||
'contextUsage.mobile.usage': 'Zużycie',
|
||||
'contextUsage.mobile.usedTokens': 'Zużyte tokeny',
|
||||
'contextUsage.tooltip.contextLimit': 'Limit kontekstu: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Limit wyjścia: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Koszt: {cost}',
|
||||
'contextUsage.tooltip.usedTokens': 'Zużyte tokeny: {tokens}',
|
||||
'desktopHostSwitcher.actions.add': 'Dodaj',
|
||||
'desktopHostSwitcher.actions.addInstance': 'Dodaj instancję',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fontes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Limpar pesquisa',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrelas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Atualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositório de origem",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Carregando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Carregando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Carregando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconhecido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Chaves de acesso",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
"contextUsage.mobile.contextLimit": "Limite de contexto",
|
||||
"contextUsage.mobile.outputLimit": "Limite de saída",
|
||||
"contextUsage.mobile.cost": "Custo",
|
||||
"contextUsage.mobile.usage": "Uso",
|
||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Limite de contexto: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Limite de saída: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Custo: {cost}",
|
||||
"contextSidebar.session.untitled": "Sessão sem título",
|
||||
"contextSidebar.empty.openSession": "Abrir uma sessão para inspecionar o contexto.",
|
||||
"contextSidebar.section.context": "Contexto",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Revertido para {preview}",
|
||||
"chat.revert.toast.redo": "Refeito",
|
||||
"chat.revert.toast.restored": "Todas as mensagens restauradas",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Conversa interrompida",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "O OpenCode foi reiniciado enquanto uma resposta ainda estava em andamento. Envie uma mensagem para continuar.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Abrir sessão",
|
||||
"chat.errorBoundary.title": "Erro na conversa",
|
||||
"chat.errorBoundary.description": "A interface da conversa encontrou um erro. Isso pode ter sido causado por um problema temporário de rede ou por dados de mensagem corrompidos.",
|
||||
"chat.errorBoundary.sessionLabel": "Sessão",
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Вручну",
|
||||
"settings.skills.catalog.page.mode.external": "зовнішній",
|
||||
"settings.skills.catalog.page.title": "Каталог навичок",
|
||||
'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.',
|
||||
'settings.skills.catalog.page.section.sources': 'Джерела',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…',
|
||||
'settings.skills.catalog.page.search.clear': 'Очистити пошук',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Зірок: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Оновлено {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички",
|
||||
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
|
||||
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
|
||||
"settings.skills.catalog.page.loading.more": "Завантаження...",
|
||||
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "встановлено ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
|
||||
"settings.skills.catalog.page.badge.unknown": "невідомий",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "за",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
|
||||
"settings.openchamber.passkeys.title": "Ключі доступу",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Використані токени",
|
||||
"contextUsage.mobile.contextLimit": "Обмеження контексту",
|
||||
"contextUsage.mobile.outputLimit": "Ліміт виводу",
|
||||
"contextUsage.mobile.cost": "Вартість",
|
||||
"contextUsage.mobile.usage": "Використання",
|
||||
"contextUsage.tooltip.usedTokens": "Використані токени: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Обмеження контексту: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Ліміт виводу: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Вартість: {cost}",
|
||||
"contextSidebar.session.untitled": "Сесія без назви",
|
||||
"contextSidebar.empty.openSession": "Відкрийте сесію, щоб перевірити контекст.",
|
||||
"contextSidebar.section.context": "Контекст",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Відкочено до {preview}",
|
||||
"chat.revert.toast.redo": "Повторено",
|
||||
"chat.revert.toast.restored": "Всі повідомлення відновлено",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Чат перервано",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "OpenCode перезапустився, поки відповідь ще формувалася. Надішліть повідомлення, щоб продовжити.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Відкрити сесію",
|
||||
"chat.errorBoundary.title": "Помилка чату",
|
||||
"chat.errorBoundary.description": "В інтерфейсі чату сталася помилка. Причиною може бути тимчасова проблема з мережею або пошкоджені дані повідомлення.",
|
||||
"chat.errorBoundary.sessionLabel": "Сесія",
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手动',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': '技能目录',
|
||||
'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。',
|
||||
'settings.skills.catalog.page.section.sources': '来源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜索',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能数:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星标:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新于 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜索结果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '来源仓库',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能',
|
||||
'settings.skills.catalog.page.loading.catalog': '加载中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
|
||||
'settings.skills.catalog.page.loading.more': '加载中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能',
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安装({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目录',
|
||||
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
'contextUsage.mobile.contextLimit': '上下文上限',
|
||||
'contextUsage.mobile.outputLimit': '输出上限',
|
||||
'contextUsage.mobile.cost': '成本',
|
||||
'contextUsage.mobile.usage': '使用率',
|
||||
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '输出上限:{tokens}',
|
||||
'contextUsage.tooltip.cost': '成本:{cost}',
|
||||
'contextSidebar.session.untitled': '未命名会话',
|
||||
'contextSidebar.empty.openSession': '请先打开会话以查看上下文。',
|
||||
'contextSidebar.section.context': '上下文',
|
||||
@@ -2388,6 +2390,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '已撤回至 {preview}',
|
||||
'chat.revert.toast.redo': '已重做',
|
||||
'chat.revert.toast.restored': '已恢复全部消息',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '聊天已中断',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode 在回复仍在生成时重启了。发送一条消息以继续。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '打开会话',
|
||||
'chat.errorBoundary.title': '聊天错误',
|
||||
'chat.errorBoundary.description': '聊天界面发生错误,可能是临时网络问题或消息数据损坏导致。',
|
||||
'chat.errorBoundary.sessionLabel': '会话',
|
||||
|
||||
@@ -871,16 +871,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'Skills 目錄',
|
||||
'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。',
|
||||
'settings.skills.catalog.page.section.sources': '來源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜尋',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能數:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星標:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新於 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜尋結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': '載入中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在載入 skills...',
|
||||
'settings.skills.catalog.page.loading.more': '載入中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills',
|
||||
@@ -888,7 +898,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安裝({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
|
||||
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1612,10 +1612,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
'contextUsage.mobile.contextLimit': '上下文上限',
|
||||
'contextUsage.mobile.outputLimit': '輸出上限',
|
||||
'contextUsage.mobile.cost': '成本',
|
||||
'contextUsage.mobile.usage': '使用率',
|
||||
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '輸出上限:{tokens}',
|
||||
'contextUsage.tooltip.cost': '成本:{cost}',
|
||||
'contextSidebar.session.untitled': '未命名會話',
|
||||
'contextSidebar.empty.openSession': '請先開啟會話以查看上下文。',
|
||||
'contextSidebar.section.context': '上下文',
|
||||
@@ -2392,6 +2394,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '已收回至 {preview}',
|
||||
'chat.revert.toast.redo': '已重做',
|
||||
'chat.revert.toast.restored': '已恢復全部訊息',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '聊天已中斷',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode 在回覆仍在產生時重新啟動。傳送訊息以繼續。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '開啟會話',
|
||||
'chat.errorBoundary.title': '聊天錯誤',
|
||||
'chat.errorBoundary.description': '聊天介面發生錯誤,可能是暫時網路問題或訊息資料損毀導致。',
|
||||
'chat.errorBoundary.sessionLabel': '會話',
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
export const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
export const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
export const GITLAB_ISSUE_CONTEXT_PREFIX = 'GitLab issue context (JSON)';
|
||||
export const GITLAB_MR_CONTEXT_PREFIX = 'GitLab merge request context (JSON)';
|
||||
export const GITEA_ISSUE_CONTEXT_PREFIX = 'Gitea issue context (JSON)';
|
||||
export const GITEA_PR_CONTEXT_PREFIX = 'Gitea pull request context (JSON)';
|
||||
|
||||
export const FORGE_CONTEXT_PREFIXES = [
|
||||
GITHUB_ISSUE_CONTEXT_PREFIX,
|
||||
GITHUB_PR_CONTEXT_PREFIX,
|
||||
GITLAB_ISSUE_CONTEXT_PREFIX,
|
||||
GITLAB_MR_CONTEXT_PREFIX,
|
||||
GITEA_ISSUE_CONTEXT_PREFIX,
|
||||
GITEA_PR_CONTEXT_PREFIX,
|
||||
];
|
||||
|
||||
export const startsWithForgeContextPrefix = (text: string): boolean =>
|
||||
FORGE_CONTEXT_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
|
||||
export const isSyntheticPart = (part: Part | undefined): boolean => {
|
||||
if (!part || typeof part !== "object") {
|
||||
@@ -45,7 +61,7 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
|
||||
}
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX) || trimmed.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
return startsWithForgeContextPrefix(trimmed);
|
||||
};
|
||||
|
||||
// If there are non-synthetic parts, filter out synthetic ones
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getCurrentIntlLocale } from './i18n';
|
||||
|
||||
|
||||
export const formatMoney = (value: number): string => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(0);
|
||||
}
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
maximumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
}).format(value);
|
||||
};
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface SessionKnowledge {
|
||||
/** Empty when the session already carries what it needs. */
|
||||
@@ -86,14 +87,17 @@ const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: {
|
||||
/** What the session is carrying, for display. Never throws; shows nothing instead. */
|
||||
export const fetchSessionKnowledgeSummary = async (
|
||||
directory: string | null,
|
||||
sessionId?: string | null,
|
||||
): Promise<SessionKnowledgeSummary> => {
|
||||
if (!directory) {
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ directory });
|
||||
if (sessionId) params.set('sessionId', sessionId);
|
||||
const response = await runtimeFetch(
|
||||
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`,
|
||||
`/api/session-knowledge/summary?${params.toString()}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -112,3 +116,29 @@ export const fetchSessionKnowledgeSummary = async (
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
};
|
||||
|
||||
export type SessionProjectContextPins = { notes: string[]; plans: string[] };
|
||||
|
||||
const sessionProjectContextPinsResponseSchema = z.object({
|
||||
pins: z.object({ notes: z.array(z.string()), plans: z.array(z.string()) }),
|
||||
});
|
||||
|
||||
export const setSessionProjectContextPin = async (
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
kind: 'note' | 'plan',
|
||||
id: string,
|
||||
pinned: boolean,
|
||||
): Promise<SessionProjectContextPins | null> => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/session-knowledge/pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory, sessionId, kind, id, pinned }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return sessionProjectContextPinsResponseSchema.parse(await response.json()).pins;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,15 +39,4 @@ describe('settings search', () => {
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true);
|
||||
});
|
||||
|
||||
test('finds coming-soon messenger placeholders', () => {
|
||||
const results = buildSettingsSearchResults({
|
||||
query: 'discord',
|
||||
runtimeCtx,
|
||||
t,
|
||||
getPageTitle: (page) => page,
|
||||
});
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.messengers.discord')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1015,26 +1015,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'integrations.messengers',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.title',
|
||||
keywords: ['messenger', 'discord', 'telegram', 'bot', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.messengers.discord',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.discord.name',
|
||||
descriptionKey: 'settings.integrations.messengers.discord.description',
|
||||
keywords: ['discord', 'bot', 'messenger', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.messengers.telegram',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.telegram.name',
|
||||
descriptionKey: 'settings.integrations.messengers.telegram.description',
|
||||
keywords: ['telegram', 'bot', 'messenger', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.third-party',
|
||||
page: 'integrations',
|
||||
|
||||
@@ -88,7 +88,7 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
descriptionKey: 'contextRail.surface.editor.description',
|
||||
defaultWidthFraction: 3 / 5,
|
||||
mode: 'file',
|
||||
icon: 'braces',
|
||||
icon: 'file-edit',
|
||||
labelKey: 'contextPanel.mode.files',
|
||||
availability: 'always',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
|
||||
const { getFreshestPrStatusForBranch, getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
@@ -38,6 +38,34 @@ describe("GitHub PR status cache ownership", () => {
|
||||
expect(new Set([originA, upstreamA, originB]).size).toBe(3)
|
||||
})
|
||||
|
||||
test("passive branch readers follow the freshest remote-keyed status", () => {
|
||||
const automatic = getGitHubPrStatusKey("/repo", "feature")
|
||||
const origin = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||
useGitHubPrStatusStore.getState().ensureEntry(automatic)
|
||||
useGitHubPrStatusStore.getState().ensureEntry(origin)
|
||||
useGitHubPrStatusStore.getState().updateStatus(automatic, () => ({
|
||||
connected: true,
|
||||
pr: { number: 7, title: "old", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
|
||||
checks: { state: "pending", total: 3, success: 1, failure: 0, pending: 2 },
|
||||
}))
|
||||
useGitHubPrStatusStore.getState().updateStatus(origin, () => ({
|
||||
connected: true,
|
||||
pr: { number: 7, title: "current", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
|
||||
checks: { state: "success", total: 3, success: 3, failure: 0, pending: 0 },
|
||||
}))
|
||||
useGitHubPrStatusStore.setState((state) => ({
|
||||
entries: {
|
||||
...state.entries,
|
||||
[automatic]: { ...state.entries[automatic], lastRefreshAt: 1 },
|
||||
[origin]: { ...state.entries[origin], lastRefreshAt: 2 },
|
||||
},
|
||||
}))
|
||||
|
||||
const freshest = getFreshestPrStatusForBranch(useGitHubPrStatusStore.getState().entries, "/repo", "feature")
|
||||
expect(freshest?.pr?.title).toBe("current")
|
||||
expect(freshest?.checks?.pending).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects a response after params change", async () => {
|
||||
const request = deferred<GitHubPullRequestStatus>()
|
||||
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
|
||||
|
||||
@@ -238,11 +238,11 @@ const findResolvedSiblingEntry = (
|
||||
* instead of a single key: the entry being actively watched/refreshed may be
|
||||
* keyed by a concrete remote while the 'auto' entry goes stale.
|
||||
*/
|
||||
export const getFreshestPrStatusForBranch = (
|
||||
const getFreshestPrEntryForBranch = (
|
||||
entries: Record<string, PrStatusEntry>,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): GitHubPullRequestStatus | null => {
|
||||
): PrStatusEntry | null => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
let best: PrStatusEntry | null = null;
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
@@ -260,7 +260,15 @@ export const getFreshestPrStatusForBranch = (
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best?.status ?? null;
|
||||
return best;
|
||||
};
|
||||
|
||||
export const getFreshestPrStatusForBranch = (
|
||||
entries: Record<string, PrStatusEntry>,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): GitHubPullRequestStatus | null => {
|
||||
return getFreshestPrEntryForBranch(entries, directory, branch)?.status ?? null;
|
||||
};
|
||||
|
||||
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
|
||||
@@ -951,23 +959,39 @@ const summarySignature = (s: PrVisualSummary): string =>
|
||||
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
|
||||
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
|
||||
|
||||
const getCachedPrSummary = (cacheKey: string, entry: PrStatusEntry | null | undefined): PrVisualSummary | null => {
|
||||
const summary = entry ? deriveSummary(entry) : null;
|
||||
if (!summary) {
|
||||
prSummaryCacheByKey.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sig = summarySignature(summary);
|
||||
const cached = prSummaryCacheByKey.get(cacheKey);
|
||||
if (cached?.sig === sig) return cached.summary;
|
||||
|
||||
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
||||
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
||||
}
|
||||
prSummaryCacheByKey.set(cacheKey, { sig, summary });
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
|
||||
return useGitHubPrStatusStore((state) => {
|
||||
if (!key) return null;
|
||||
const entry = state.entries[key];
|
||||
const summary = entry ? deriveSummary(entry) : null;
|
||||
if (!summary) {
|
||||
prSummaryCacheByKey.delete(key);
|
||||
return null;
|
||||
}
|
||||
const sig = summarySignature(summary);
|
||||
const cached = prSummaryCacheByKey.get(key);
|
||||
if (cached && cached.sig === sig) return cached.summary;
|
||||
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
||||
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
||||
}
|
||||
prSummaryCacheByKey.set(key, { sig, summary });
|
||||
return summary;
|
||||
return getCachedPrSummary(key, state.entries[key]);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFreshestPrVisualSummaryForBranch = (
|
||||
directory: string | null,
|
||||
branch: string | null,
|
||||
): PrVisualSummary | null => {
|
||||
const cacheKey = directory && branch ? JSON.stringify(['branch', getRuntimeKey(), directory, branch]) : null;
|
||||
return useGitHubPrStatusStore((state) => {
|
||||
if (!directory || !branch || !cacheKey) return null;
|
||||
return getCachedPrSummary(cacheKey, getFreshestPrEntryForBranch(state.entries, directory, branch));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useProjectsStore', () => ({
|
||||
useProjectsStore: {
|
||||
getState: () => ({
|
||||
getActiveProject: () => null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: async () => new Response('{}', { status: 500 }),
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useSkillsStore', () => ({
|
||||
invalidateSkillsLoadCache: () => undefined,
|
||||
refreshSkillsAfterOpenCodeRestart: async () => undefined,
|
||||
useSkillsStore: {
|
||||
getState: () => ({}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/configUpdate', () => ({
|
||||
startConfigUpdate: () => undefined,
|
||||
finishConfigUpdate: () => undefined,
|
||||
updateConfigUpdateMessage: () => undefined,
|
||||
}));
|
||||
|
||||
const { useSkillsCatalogStore } = await import('./useSkillsCatalogStore');
|
||||
|
||||
describe('skills catalog ClawHub label', () => {
|
||||
beforeEach(() => {
|
||||
useSkillsCatalogStore.setState({
|
||||
sources: useSkillsCatalogStore.getState().sources,
|
||||
});
|
||||
});
|
||||
|
||||
test('fallback sources label ClawHub correctly', () => {
|
||||
const clawhub = useSkillsCatalogStore.getState().sources.find((source) => source.id === 'clawdhub');
|
||||
expect(clawhub).toBeDefined();
|
||||
expect(clawhub?.label).toBe('ClawHub');
|
||||
});
|
||||
});
|
||||
@@ -30,11 +30,27 @@ const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'clawdhub',
|
||||
label: 'ClawHub',
|
||||
description: 'Community skill registry with vector search',
|
||||
source: 'clawdhub:registry',
|
||||
sourceType: 'clawdhub',
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: "OpenAI's curated skills",
|
||||
source: 'openai/skills',
|
||||
defaultSubpath: 'skills/.curated',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
description: "Cursor's plugin skills",
|
||||
source: 'cursor/plugins',
|
||||
defaultSubpath: 'pstack/skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
{
|
||||
id: 'mattpocock',
|
||||
label: 'Matt Pocock',
|
||||
description: 'Matt Pocock skills collection',
|
||||
source: 'mattpocock/skills',
|
||||
sourceType: 'github',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,6 +58,8 @@ const SKILLS_CATALOG_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_SKILLS_CATALOG_CACHE_KEY = '__default__';
|
||||
const skillsCatalogLastLoadedAt = new Map<string, number>();
|
||||
const skillsCatalogLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
const sourceLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
let activeSourceLoads = 0;
|
||||
|
||||
const getSkillsCatalogCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
|
||||
@@ -71,13 +89,10 @@ export interface SkillsCatalogState {
|
||||
sources: SkillsCatalogSource[];
|
||||
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
||||
selectedSourceId: string | null;
|
||||
pageInfoBySource: Record<string, { nextCursor?: string | null }>;
|
||||
loadedSourceIds: Record<string, boolean>;
|
||||
clawdhubHasMoreBySource: Record<string, boolean>;
|
||||
|
||||
isLoadingCatalog: boolean;
|
||||
isLoadingSource: boolean;
|
||||
isLoadingMore: boolean;
|
||||
isScanning: boolean;
|
||||
isInstalling: boolean;
|
||||
|
||||
@@ -91,7 +106,6 @@ export interface SkillsCatalogState {
|
||||
|
||||
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadSource: (sourceId: string, options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
loadMoreClawdHub: () => Promise<boolean>;
|
||||
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
||||
installSkills: (request: SkillsInstallRequest, options?: { directory?: string | null }) => Promise<SkillsInstallResponse>;
|
||||
}
|
||||
@@ -102,13 +116,10 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
sources: FALLBACK_SOURCES,
|
||||
itemsBySource: {},
|
||||
selectedSourceId: FALLBACK_SOURCES[0]?.id ?? null,
|
||||
pageInfoBySource: {},
|
||||
loadedSourceIds: {},
|
||||
clawdhubHasMoreBySource: {},
|
||||
|
||||
isLoadingCatalog: false,
|
||||
isLoadingSource: false,
|
||||
isLoadingMore: false,
|
||||
isScanning: false,
|
||||
isInstalling: false,
|
||||
|
||||
@@ -141,9 +152,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
pageInfoBySource: get().pageInfoBySource,
|
||||
loadedSourceIds: get().loadedSourceIds,
|
||||
clawdhubHasMoreBySource: get().clawdhubHasMoreBySource,
|
||||
};
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
@@ -168,9 +177,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
|
||||
const sources = (payload.sources && payload.sources.length > 0) ? payload.sources : previous.sources;
|
||||
const itemsBySource = options?.refresh ? {} : (get().itemsBySource || {});
|
||||
const pageInfoBySource = options?.refresh ? {} : (get().pageInfoBySource || {});
|
||||
const loadedSourceIds = options?.refresh ? {} : (get().loadedSourceIds || {});
|
||||
const clawdhubHasMoreBySource = options?.refresh ? {} : (get().clawdhubHasMoreBySource || {});
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
@@ -180,9 +187,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
set({
|
||||
sources,
|
||||
itemsBySource,
|
||||
pageInfoBySource,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
selectedSourceId,
|
||||
});
|
||||
|
||||
@@ -197,9 +202,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
set({
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
pageInfoBySource: previous.pageInfoBySource,
|
||||
loadedSourceIds: previous.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: previous.clawdhubHasMoreBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
|
||||
@@ -222,136 +225,83 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent loads of the same source: the background
|
||||
// loader effect can restart while a request for this source is
|
||||
// already in flight.
|
||||
if (!options?.refresh) {
|
||||
const inFlight = sourceLoadInFlight.get(sourceId);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
activeSourceLoads += 1;
|
||||
set({ isLoadingSource: true, lastCatalogError: null });
|
||||
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
||||
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor: null } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: { ...state.clawdhubHasMoreBySource, [sourceId]: false },
|
||||
}));
|
||||
return true;
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const fallbackPayload = (await fallback.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
const fallbackItems = fallbackPayload?.itemsBySource?.[sourceId];
|
||||
if (fallback.ok && fallbackPayload?.ok && Array.isArray(fallbackItems)) {
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: fallbackItems },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
set({
|
||||
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = payload?.items || [];
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({
|
||||
lastCatalogError: payload?.error || { kind: 'unknown', message: `Failed to load source (${response.status})` },
|
||||
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = payload?.items || [];
|
||||
const nextCursor = payload?.nextCursor ?? null;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: { ...state.itemsBySource, [sourceId]: items },
|
||||
pageInfoBySource: { ...state.pageInfoBySource, [sourceId]: { nextCursor } },
|
||||
loadedSourceIds: { ...state.loadedSourceIds, [sourceId]: true },
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[sourceId]: items.length > 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({
|
||||
lastCatalogError: { kind: 'unknown', message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoadingSource: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadMoreClawdHub: async () => {
|
||||
const selectedSourceId = get().selectedSourceId;
|
||||
if (!selectedSourceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageInfo = get().pageInfoBySource[selectedSourceId];
|
||||
const cursor = pageInfo?.nextCursor || null;
|
||||
|
||||
set({ isLoadingMore: true });
|
||||
try {
|
||||
const currentDirectory = getRequestDirectory();
|
||||
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
|
||||
if (currentDirectory) {
|
||||
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
|
||||
}
|
||||
if (cursor) {
|
||||
parts.push(`cursor=${encodeURIComponent(cursor)}`);
|
||||
}
|
||||
const queryParams = `?${parts.join('&')}`;
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCursor = payload.nextCursor ?? null;
|
||||
const currentItems = get().itemsBySource[selectedSourceId] || [];
|
||||
const items = payload.items || [];
|
||||
const merged = new Map(currentItems.map((item) => [`${item.sourceId}:${item.skillDir}`, item]));
|
||||
let newCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const key = `${item.sourceId}:${item.skillDir}`;
|
||||
if (!merged.has(key)) {
|
||||
newCount += 1;
|
||||
} finally {
|
||||
activeSourceLoads -= 1;
|
||||
if (activeSourceLoads === 0) {
|
||||
set({ isLoadingSource: false });
|
||||
}
|
||||
merged.set(key, item);
|
||||
}
|
||||
})();
|
||||
|
||||
const noMore = items.length === 0 || newCount === 0;
|
||||
|
||||
set((state) => ({
|
||||
itemsBySource: {
|
||||
...state.itemsBySource,
|
||||
[selectedSourceId]: Array.from(merged.values()),
|
||||
},
|
||||
pageInfoBySource: {
|
||||
...state.pageInfoBySource,
|
||||
[selectedSourceId]: { nextCursor },
|
||||
},
|
||||
clawdhubHasMoreBySource: {
|
||||
...state.clawdhubHasMoreBySource,
|
||||
[selectedSourceId]: !noMore,
|
||||
},
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
sourceLoadInFlight.set(sourceId, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
set({ isLoadingMore: false });
|
||||
if (sourceLoadInFlight.get(sourceId) === request) {
|
||||
sourceLoadInFlight.delete(sourceId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
|
||||
|
||||
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
|
||||
|
||||
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with active tool parts and no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the parts, see openchamber#2577 / anomalyco/opencode#19023). The active parts are finalized locally as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale `running` refresh cannot regress it.
|
||||
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the message or parts, see openchamber#2577 / anomalyco/opencode#19023). The unfinished assistant message is completed locally with `MessageAbortedError`, including text-only turns and turns whose tools had already finished, so the chat shows a visible interrupted state. Any active parts are also finalized as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event can supersede it while a stale unfinished refresh cannot regress the locally finalized message or parts.
|
||||
|
||||
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* process dies mid-turn, the persisted turn never settles — the trailing
|
||||
* assistant message has no time.completed and its tool parts stay running.
|
||||
* Once the session is authoritatively settled, `interruptedTurnToolParts`
|
||||
* finalizes the orphaned parts locally.
|
||||
* completes the assistant message as aborted and finalizes orphaned parts.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -74,10 +74,15 @@ describe("interruptedTurnToolParts (#2577)", () => {
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const part = result!.parts[0] as { state: { status: string; error: string; time: { end: number } } }
|
||||
const part = result!.parts![0] as { state: { status: string; error: string; time: { end: number } } }
|
||||
expect(part.state.status).toBe("error")
|
||||
expect(part.state.error).toBe("Interrupted")
|
||||
expect(part.state.time.end).toBe(5000)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
})
|
||||
|
||||
test("busy session is never marked (live work)", () => {
|
||||
@@ -137,16 +142,43 @@ describe("interruptedTurnToolParts (#2577)", () => {
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const statuses = result!.parts.map((part) => (part as { state: { status: string } }).state.status)
|
||||
const statuses = result!.parts!.map((part) => (part as { state: { status: string } }).state.status)
|
||||
expect(statuses).toEqual(["error", "completed", "error"])
|
||||
})
|
||||
|
||||
test("no active parts → no change", () => {
|
||||
test("unfinished assistant with no tools is completed as aborted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [completedTool("tool_2", "msg_1")] },
|
||||
part: {},
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.parts).toBe(undefined)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
})
|
||||
|
||||
test("completed tools are untouched while the unfinished assistant is aborted", () => {
|
||||
const completed = completedTool("tool_2", "msg_1")
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [completed] },
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.parts).toBe(undefined)
|
||||
expect(store.part.msg_1[0]).toBe(completed)
|
||||
expect(result!.messages[0]).toEqual({
|
||||
...unfinishedAssistantMessage("msg_1"),
|
||||
time: { created: 10, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -373,6 +373,27 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
expect(permissionAutoAcceptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("transfers draft project context pins only to the session it creates", async () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
projectContextPins: { notes: ["note-a"], plans: [] },
|
||||
})
|
||||
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
|
||||
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[0]?.metadata).toEqual({
|
||||
openchamber: {
|
||||
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
|
||||
},
|
||||
})
|
||||
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[1]?.metadata).toBe(undefined)
|
||||
})
|
||||
|
||||
test("uses the server-authoritative directory after worktree session creation", async () => {
|
||||
createdSessionDirectory = "/canonical/worktree"
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
|
||||
@@ -119,6 +119,31 @@ describe("materializeSessionSnapshots", () => {
|
||||
expect(result.part.msg_1[0]).toBe(livePart)
|
||||
})
|
||||
|
||||
test("preserves a locally aborted assistant message when a stale unfinished snapshot arrives", () => {
|
||||
const unfinishedMessage = message("msg_1")
|
||||
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
|
||||
const abortedMessage: Message = {
|
||||
...unfinishedMessage,
|
||||
time: { created: 1, completed: 5000 },
|
||||
error: { name: "MessageAbortedError", data: { message: "aborted" } },
|
||||
}
|
||||
const staleMessage = message("msg_1")
|
||||
const state = {
|
||||
message: { ses_1: [abortedMessage] },
|
||||
part: { msg_1: [] },
|
||||
}
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
state,
|
||||
"ses_1",
|
||||
[{ info: staleMessage, parts: [] }],
|
||||
)
|
||||
|
||||
expect(result.message).toBe(state.message)
|
||||
expect(result.message.ses_1[0]).toBe(abortedMessage)
|
||||
expect(result.message.ses_1[0]).not.toBe(staleMessage)
|
||||
})
|
||||
|
||||
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
|
||||
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
|
||||
const serverPart = part("prt_server", "msg_1", "text", "Hello")
|
||||
|
||||
@@ -270,6 +270,7 @@ export type NewSessionDraftState = {
|
||||
initialPrompt?: string
|
||||
syntheticParts?: SyntheticContextPart[]
|
||||
targetFolderId?: string
|
||||
projectContextPins?: { notes: string[]; plans: string[] }
|
||||
}
|
||||
|
||||
export type ViewportAnchor = {
|
||||
@@ -319,6 +320,7 @@ export type SessionUIState = {
|
||||
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
|
||||
setDraftPreserveDirectoryOverride: (value: boolean) => void
|
||||
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
|
||||
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
|
||||
acknowledgeSessionAbort: (sessionId: string) => void
|
||||
clearAbortPrompt: () => void
|
||||
armAbortPrompt: (durationMs?: number) => number | null
|
||||
@@ -726,7 +728,15 @@ export async function materializeOpenDraftSession(selection: {
|
||||
|
||||
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
|
||||
|
||||
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
||||
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const created = await store.createSession(
|
||||
draft.title,
|
||||
draftDirectoryOverride,
|
||||
draft.parentID ?? null,
|
||||
draftPins.notes.length > 0 || draftPins.plans.length > 0
|
||||
? { openchamber: { project_context_pins: draftPins } }
|
||||
: undefined,
|
||||
)
|
||||
if (!created?.id) throw new Error("Failed to create session")
|
||||
|
||||
// The server response is authoritative. It may canonicalize a requested
|
||||
@@ -1026,6 +1036,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
initialPrompt: options?.initialPrompt,
|
||||
syntheticParts: options?.syntheticParts,
|
||||
targetFolderId: options?.targetFolderId,
|
||||
projectContextPins: options?.projectContextPins,
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -1138,6 +1149,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } }
|
||||
}),
|
||||
|
||||
setDraftProjectContextPin: (kind, id, pinned) =>
|
||||
set((s) => {
|
||||
if (!s.newSessionDraft?.open) return s
|
||||
const pins = s.newSessionDraft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const key = kind === "note" ? "notes" : "plans"
|
||||
const next = new Set(pins[key])
|
||||
if (pinned) next.add(id)
|
||||
else next.delete(id)
|
||||
return {
|
||||
newSessionDraft: {
|
||||
...s.newSessionDraft,
|
||||
projectContextPins: { ...pins, [key]: [...next] },
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
acknowledgeSessionAbort: (sessionId) =>
|
||||
set((s) => {
|
||||
const flags = new Map(s.sessionAbortFlags)
|
||||
@@ -1578,14 +1605,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteSession — calls SDK, SSE event updates child store
|
||||
// ---------------------------------------------------------------------------
|
||||
deleteSession: async (id, options) => {
|
||||
const deleted = await deleteSessionAction(id, options)
|
||||
if (deleted) {
|
||||
// Nothing to forget here any more: what a session was told lives in its
|
||||
// own metadata and goes with it.
|
||||
}
|
||||
return deleted
|
||||
},
|
||||
deleteSession: async (id, options) => deleteSessionAction(id, options),
|
||||
|
||||
deleteSessions: async (ids, options) => {
|
||||
const result = await deleteSessionsAction(ids, options)
|
||||
|
||||
@@ -70,6 +70,7 @@ import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-mem
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
import { formatMessage, useI18nStore } from "@/lib/i18n"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
@@ -456,6 +457,32 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
||||
}
|
||||
|
||||
const notification = properties as UiNotificationPayload
|
||||
const kind = asOptionalString(notification.kind)
|
||||
const sessionId = asOptionalString(notification.sessionId)
|
||||
const directory = asOptionalString(notification.directory)
|
||||
?? (fallbackDirectory !== "global" ? fallbackDirectory : "")
|
||||
|
||||
if (kind === "opencode-restart-interrupted") {
|
||||
const dictionary = useI18nStore.getState().dictionary
|
||||
const title = formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.title")
|
||||
const options = {
|
||||
id: "opencode-restart-interrupted",
|
||||
description: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.description"),
|
||||
duration: Infinity,
|
||||
}
|
||||
if (sessionId && directory) {
|
||||
toast.info(title, {
|
||||
...options,
|
||||
action: {
|
||||
label: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.openSession"),
|
||||
onClick: () => openSessionFromToast(sessionId, directory),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
toast.info(title, options)
|
||||
}
|
||||
}
|
||||
|
||||
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
||||
return true
|
||||
}
|
||||
@@ -469,9 +496,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
||||
title: asOptionalString(notification.title),
|
||||
body: asOptionalString(notification.body),
|
||||
tag: asOptionalString(notification.tag),
|
||||
kind: asOptionalString(notification.kind),
|
||||
sessionId: asOptionalString(notification.sessionId),
|
||||
directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined),
|
||||
kind,
|
||||
sessionId,
|
||||
directory: directory || undefined,
|
||||
requireHidden: notification.requireHidden === true,
|
||||
}).catch((error) => {
|
||||
console.warn("[notifications] failed to dispatch UI notification", error)
|
||||
@@ -632,15 +659,24 @@ async function resyncDirectorySessionStatuses(
|
||||
if (mode === "authoritative") {
|
||||
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
|
||||
// An authoritative snapshot that settles sessions previously observed
|
||||
// busy/retry can orphan running tool parts (managed process died
|
||||
// mid-turn, #2577): finalize them now. The snapshot write above already
|
||||
// lowered their status to explicit idle, which is the gate the helper
|
||||
// requires — a session the snapshot reports busy stays untouched.
|
||||
// busy/retry can leave their trailing assistant message and tool parts
|
||||
// unfinished (managed process died mid-turn, #2577): finalize them now.
|
||||
// The snapshot write above already lowered their status to explicit idle,
|
||||
// which is the gate the helper requires — a session the snapshot reports
|
||||
// busy stays untouched.
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
|
||||
if (interrupted) {
|
||||
if (!interrupted.parts) {
|
||||
store.setState((state) => ({
|
||||
message: { ...state.message, [sessionId]: interrupted.messages },
|
||||
}))
|
||||
continue
|
||||
}
|
||||
const interruptedParts = interrupted.parts
|
||||
store.setState((state) => ({
|
||||
part: { ...state.part, [interrupted.messageID]: interrupted.parts },
|
||||
message: { ...state.message, [sessionId]: interrupted.messages },
|
||||
part: { ...state.part, [interrupted.messageID]: interruptedParts },
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1804,18 +1840,32 @@ export function handleEvent(
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
// The reducer already wrote the idle/error status into `draft`; mark the
|
||||
// orphaned tools using the batched state and publish through the batch.
|
||||
// The reducer already wrote the idle/error status into `draft`; finalize
|
||||
// the interrupted message and orphaned tools through the same batch.
|
||||
if (sessionID) {
|
||||
const interrupted = interruptedTurnToolParts(state, sessionID)
|
||||
if (interrupted) {
|
||||
cloneField("part", (value) => ({ ...(value ?? {}) }))
|
||||
;(draft as DirectoryStore).part[interrupted.messageID] = interrupted.parts
|
||||
cloneField("message", (value) => ({ ...value }))
|
||||
draft.message[sessionID] = interrupted.messages
|
||||
if (interrupted.parts) {
|
||||
cloneField("part", (value) => ({ ...(value ?? {}) }))
|
||||
draft.part[interrupted.messageID] = interrupted.parts
|
||||
}
|
||||
if (batch) {
|
||||
batch.states.set(store, draft as DirectoryStore)
|
||||
batch.changedStores.add(store)
|
||||
} else {
|
||||
store.setState({ part: { ...(store.getState().part), [interrupted.messageID]: interrupted.parts } })
|
||||
const currentState = store.getState()
|
||||
if (interrupted.parts) {
|
||||
store.setState({
|
||||
message: { ...currentState.message, [sessionID]: interrupted.messages },
|
||||
part: { ...currentState.part, [interrupted.messageID]: interrupted.parts },
|
||||
})
|
||||
} else {
|
||||
store.setState({
|
||||
message: { ...currentState.message, [sessionID]: interrupted.messages },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1829,29 +1879,31 @@ export function handleEvent(
|
||||
//
|
||||
// A managed OpenCode process can die mid-turn (crash, health-check restart).
|
||||
// The persisted turn then never settles: the trailing assistant message has
|
||||
// no `time.completed` and its tool parts stay `pending`/`running` forever —
|
||||
// the server never finalizes them (anomalyco/opencode#19023). The
|
||||
// no `time.completed`, and any tool parts can stay `pending`/`running`
|
||||
// forever — the server never finalizes them (anomalyco/opencode#19023). The
|
||||
// settle-triggered tail refresh above refetches the same stale records, so
|
||||
// the UI would keep running tool timers and "working" styling indefinitely
|
||||
// (#2577).
|
||||
// the UI would keep the assistant message unfinished and any tool timers and
|
||||
// "working" styling active indefinitely (#2577).
|
||||
//
|
||||
// OpenCode keeps a turn's session busy while it is genuinely alive —
|
||||
// including while waiting for a question/permission reply — so once a
|
||||
// session is AUTHORITATIVELY settled (a `session.idle`/`session.error`
|
||||
// event, or an authoritative status snapshot that lowers a previously busy
|
||||
// session) and the trailing assistant message is still unfinished with
|
||||
// active tool parts and no pending question/permission, the turn is
|
||||
// definitively interrupted. Finalize the orphaned parts locally as
|
||||
// `error`/`Interrupted` with an end time — the same shape OpenCode itself
|
||||
// writes for cancelled tools. A later terminal part event or a refresh that
|
||||
// carries the true terminal state supersedes the mark; a stale refresh that
|
||||
// still reports `running` is rejected by the reducer's and the materializer's
|
||||
// final-status preservation.
|
||||
// no pending question/permission, the turn is definitively interrupted.
|
||||
// Complete the assistant message locally with MessageAbortedError and finalize
|
||||
// any orphaned parts as `error`/`Interrupted` with an end time — the same shape
|
||||
// OpenCode itself writes for cancelled tools. A later terminal event can
|
||||
// supersede the mark; a stale refresh cannot regress the locally final state.
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type SdkMessageAbortedError = Extract<NonNullable<AssistantMessage["error"]>, { name: "MessageAbortedError" }>
|
||||
type LocalMessageAbortedError = SdkMessageAbortedError & { message: string }
|
||||
|
||||
export function interruptedTurnToolParts(
|
||||
state: DirectoryStore,
|
||||
sessionID: string,
|
||||
now = Date.now(),
|
||||
): { messageID: string; parts: Part[] } | null {
|
||||
): { messageID: string; messages: Message[]; parts?: Part[] } | null {
|
||||
if ((state.question?.[sessionID] ?? []).length > 0) return null
|
||||
if ((state.permission?.[sessionID] ?? []).length > 0) return null
|
||||
|
||||
@@ -1863,37 +1915,62 @@ export function interruptedTurnToolParts(
|
||||
return null
|
||||
}
|
||||
|
||||
const messageID = getStaleRunningToolMessageID(state, sessionID)
|
||||
if (!messageID) return null
|
||||
const message = (state.message[sessionID] ?? []).find((candidate) => candidate.id === messageID)
|
||||
if (!message) return null
|
||||
if (typeof (message as { time?: { completed?: unknown } }).time?.completed === "number") {
|
||||
const messages = state.message[sessionID] ?? []
|
||||
let messageIndex = -1
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index]
|
||||
if (candidate.role === "user") return null
|
||||
if (candidate.role !== "assistant") continue
|
||||
messageIndex = index
|
||||
break
|
||||
}
|
||||
if (messageIndex < 0) return null
|
||||
|
||||
const message = messages[messageIndex]
|
||||
if (message.role !== "assistant") return null
|
||||
if (message.time.completed !== undefined) {
|
||||
// The turn finished; a missed terminal tool event is the tail refresh's
|
||||
// job, not an interruption.
|
||||
return null
|
||||
}
|
||||
|
||||
const current = state.part[messageID]
|
||||
if (!current) return null
|
||||
const messageID = message.id
|
||||
const nextMessages = [...messages]
|
||||
const error = {
|
||||
name: "MessageAbortedError",
|
||||
data: { message: "aborted" },
|
||||
message: "aborted",
|
||||
} satisfies LocalMessageAbortedError
|
||||
nextMessages[messageIndex] = {
|
||||
...message,
|
||||
time: { ...message.time, completed: now },
|
||||
error,
|
||||
}
|
||||
|
||||
let changed = false
|
||||
const nextParts = current.map((part) => {
|
||||
let partsChanged = false
|
||||
const currentParts = state.part[messageID]
|
||||
const nextParts = currentParts?.map((part) => {
|
||||
if (part.type !== "tool") return part
|
||||
const partState = (part as { state?: { status?: unknown; time?: { start?: number } } }).state
|
||||
if (!partState) return part
|
||||
if (partState.status !== "pending" && partState.status !== "running") return part
|
||||
changed = true
|
||||
if (part.state.status !== "pending" && part.state.status !== "running") return part
|
||||
partsChanged = true
|
||||
const partTime = "time" in part.state ? part.state.time : undefined
|
||||
const start = typeof partTime?.start === "number" ? partTime.start : now
|
||||
return {
|
||||
...part,
|
||||
state: {
|
||||
...partState,
|
||||
status: "error",
|
||||
...part.state,
|
||||
status: "error" as const,
|
||||
error: "Interrupted",
|
||||
time: { ...(partState.time ?? {}), end: now },
|
||||
time: { start, end: now },
|
||||
},
|
||||
} as Part
|
||||
}
|
||||
})
|
||||
return changed ? { messageID, parts: nextParts } : null
|
||||
|
||||
return {
|
||||
messageID,
|
||||
messages: nextMessages,
|
||||
parts: partsChanged ? nextParts : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user