Fix/resolve different bugs (#964)

* fix: don't overlap thinking banner over conversation

* feat: support message queue and add attention indicator

* fix(shell): use c to copy for claude

* fix: strip timestamp tags from cursor

* fix: select project when loading session from direct URL

* feat: support attached images for all providers

* feat(opencode): support permission options

* fix: resolve source control and chat UX bugs

- make staging real in the git panel: new /stage and /unstage endpoints,
  /status now reports the actual index via porcelain -z (handles renames,
  conflicts, and paths with spaces), and Stage/Unstage All run real git
  commands instead of toggling client-side state
- add branch search to the header dropdown and Branches tab
- persist the chosen permission mode per provider so a brand-new chat
  keeps it once the session id arrives, instead of reverting to default
- replace cmdk's fuzzy model search with strict token matching so
  "chatgpt" no longer surfaces unrelated models
- unit tests for the git status parser

* feat(git): commit graph in history view and cross-spawn everywhere

- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment

* fix: remove gemini support since google discontinued it

* fix: address code review findings

- validate chat image attachments server-side: only files inside the
  ~/.cloudcli/assets upload store may reach provider file reads
- harden asset serving with nosniff and attachment disposition for
  SVGs to prevent stored XSS
- show the timestamp on image-only user messages
- serialize git stage/unstage calls and defer the status re-sync so
  rapid toggles can't interleave or flicker
- use a literal hex fallback for commit ref badges (alpha suffix on a
  var() string produced invalid CSS)
- stop binding a URL session to a guening
  project instead
- add the missing attentionRequiredIndicator key to all sidebar locales
- clean up dangling conjunctions left the
  de/ru/tr/ja/zh-CN/zh-TW READMEs

* fix: address code scanning findings

- enforce a second image trust boundary in the provider builders:
  buildClaudeUserContent/buildCodexInputItems only accept files inside
  the upload store or the run's working directory (CodeQL path
  injection)
- drop an always-true selectedProject check in handleSubmit
- remove the unused resume option from spawnCursor

* fix: use CodeQL-recognized containment check for image reads

Replace the path.relative guard in isPathInsideDirectory with the
resolve + startsWith(root + sep) idiom so the path-injection barrier
is visible to code scanning; behavior is unchanged.

* fix(security): canonicalize Claude image attachment reads

Resolve image attachment paths with realpath before reading so symlinks
inside allowed roots cannot escape to arbitrary files. Preserve support for
symlinked project/upload roots and add focused coverage for both cases.

* fix: show agent subtask

* fix(sessions): title app-created sessions from the first user message

App-created sessions (started by sending a message from cloudcli) were
titled with placeholder names — "Untitled Codex Session" from the disk
indexer and, briefly, "New session" from the empty canonical upsert —
before a later sync finally settled on the right text, causing the
sidebar title to flicker.

- Codex/OpenCode synchronizers now title app-created sessions (distinct
  app id mapped to a provider id) from the first user message, while
  sessions found purely by indexing keep their existing setup. Claude
  keeps its AI-generated titles; Cursor already used the first message.
- Decode OpenCode's JSON-string-literal prompts so titles no longer
  surface wrapped in quotes; hoist unwrapJsonStringLiteral into
  shared/utils since it's now used by both the reader and synchronizer.
- Guard the sidebar upsert merge so an empty summary can never blank out
  a title that is already set.
- Add codex/opencode synchronizer tests for the app-created vs indexed
  naming paths.

* fix(chat): make message queuing reliable across sessions and turn boundaries

Queued messages had four related defects:

- A queued message flashed and then vanished at flush time: concurrent
  transcript refreshes (the `complete` handler racing the watcher-triggered
  update) could resolve out of order, letting a stale response overwrite
  newer server messages after the optimistic row was pruned. Session slots
  now carry a monotonic fetch ticket and discard stale fetch/refresh/
  fetchMore responses.

- Switching sessions flushed the previous session's queued draft into the
  newly viewed one (sending it with the wrong provider's settings, e.g. a
  Claude model into a Codex session). The composer flush is now scoped to
  its session, and a draft restored into an idle session sends after a
  short grace period so a live-run ack can cancel it.

- The thinking banner never appeared for a queued turn: the chat handler's
  session-keyed completeRun safety net could fire after a queued message
  had already started the session's next run, emitting a spurious
  `complete` that killed it. The safety net is now scoped to its own run
  via completeRunIfCurrent (with a regression test).

- Queued messages only sent while their session was being viewed. Drafts
  now persist their send options (model, effort, permissions) at queue
  time, and a new app-level useQueuedMessageAutoSend hook dispatches a
  non-viewed session's queued message as soon as its run completes, using
  the storage key as the claim ticket to prevent double sends.
This commit is contained in:
Haile
2026-07-08 11:32:32 +03:00
committed by GitHub
parent 41e0d309e0
commit 4cee5e7286
173 changed files with 4172 additions and 4296 deletions

View File

@@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
'GEMINI_API_KEY',
'GROQ_API_KEY',
'OPENROUTER_API_KEY',
];

View File

@@ -1,5 +1,3 @@
import { spawn } from 'node:child_process';
import Database from 'better-sqlite3';
import crossSpawn from 'cross-spawn';
@@ -51,23 +49,15 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
label: 'GPT-5.4 Mini',
description: 'openai - openai/gpt-5.4-mini',
},
{
value: 'google/gemini-2.5-pro',
label: 'Gemini 2.5 Pro',
description: 'google - google/gemini-2.5-pro',
},
{
value: 'google/gemini-2.5-flash',
label: 'Gemini 2.5 Flash',
description: 'google - google/gemini-2.5-flash',
},
],
DEFAULT: 'anthropic/claude-sonnet-4-5',
};
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
// child_process.spawn everywhere else.
const spawnFunction = crossSpawn;
const DATE_TOKEN = /^\d{8}$/;
const SIMPLE_NUMBER_TOKEN = /^\d$/;
const VERSION_TOKEN = /^[a-z]\d+$/i;
@@ -239,6 +229,10 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
};
};
const isSupportedOpenCodeModelId = (id: string): boolean => (
readOpenCodeModelParts(id).upstreamProvider.toLowerCase() !== 'google'
);
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
const id = readOptionalString(model.id);
if (!id) {
@@ -296,7 +290,7 @@ const readOpenCodeEffortValues = (
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
const value = readOpenCodeVerboseModelId(model);
if (!value) {
if (!value || !isSupportedOpenCodeModelId(value)) {
return null;
}
@@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti
};
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
const options: ProviderModelOption[] = ids.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const options: ProviderModelOption[] = ids
.filter(isSupportedOpenCodeModelId)
.map((value) => ({
value,
label: labelForOpenCodeModelId(value),
description: descriptionForOpenCodeModelId(value),
}));
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
?? options[0]?.value

View File

@@ -11,6 +11,7 @@ import {
normalizeSessionName,
readJsonRecord,
readOptionalString,
unwrapJsonStringLiteral,
} from '@/shared/utils.js';
type OpenCodeSessionRow = {
@@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
// Sessions started by sending a message from cloudcli carry a distinct
// app-allocated session_id mapped to the provider id. For these we title the
// conversation from the first user message the user typed, matching how the
// app titles a brand-new conversation. Sessions discovered purely by
// indexing (session_id === provider_session_id) keep OpenCode's own stored
// title.
const isAppCreated =
existingSession != null &&
existingSession.provider_session_id != null &&
existingSession.session_id !== existingSession.provider_session_id;
let nextName: string | undefined;
if (existingName && existingName !== fallbackTitle) {
nextName = existingName;
} else if (isAppCreated) {
nextName = this.readFirstUserText(db, sessionId) ?? readOptionalString(row.title);
} else {
nextName = readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
}
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
@@ -163,7 +181,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
`).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data);
return readOptionalString(data?.text);
const text = readOptionalString(data?.text);
// OpenCode persists the first prompt as a JSON string literal (e.g.
// `"hello"`), so decode it to avoid titling the session with quotes.
return text === undefined ? undefined : unwrapJsonStringLiteral(text);
} catch {
return undefined;
}

View File

@@ -2,6 +2,7 @@ import fsSync from 'node:fs';
import Database from 'better-sqlite3';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
@@ -13,6 +14,7 @@ import {
readJsonRecord,
readOptionalString,
sliceTailPage,
unwrapJsonStringLiteral,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
@@ -59,25 +61,6 @@ const formatToolContent = (value: unknown): string => {
}
};
/**
* OpenCode can persist the first prompt as a JSON string literal inside a text
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
* string literals so normal assistant/user prose remains untouched.
*/
const unwrapJsonStringLiteral = (value: string): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
return value;
}
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return unwrapJsonStringLiteral(value);
@@ -418,8 +401,13 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
}
if (partType === 'text') {
const content = extractText(partData);
if (content.trim()) {
const rawContent = extractText(partData);
// User prompts sent with attachments carry an <images_input> path
// list; strip it for display and surface the paths as images.
const { text: content, attachments } = messageRole === 'user'
? parseImagesInputTag(rawContent)
: { text: rawContent, attachments: [] };
if (content.trim() || attachments.length > 0) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
@@ -428,6 +416,7 @@ export class OpenCodeSessionsProvider implements IProviderSessions {
kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant',
content,
images: attachments.length > 0 ? attachments : undefined,
}));
}
continue;