Commit Graph
62 Commits
Author SHA1 Message Date
tao.chen 031451fe3e feat(acp): streaming WebSocket route for prompt chunks
Adds GET /api/workspaces/:id/acp/stream. Client opens a WebSocket,
sends {"type":"prompt","content":"..."}, and receives a stream of
{"type":"chunk","messageId","text"} events followed by exactly
one {"type":"complete","stopReason"} or {"type":"error","error"}.
Closing the WS early triggers session/cancel.

- internal/acp/messages.go: StreamEvent wire shape.
- internal/acp/client.go:
  - streamChs []chan StreamEvent set; AddStream / RemoveStream.
  - sendStream non-blocking fanout.
  - Client.Stream(ctx, content, out) registers out, sends prompt,
    emits complete/error after the prompt response, unregisters.
  - handleNotification fans chunk events to all stream consumers.
  - notifyWG ensures chunk ordering vs the terminal event.
- internal/acp/service.go: Service.Stream(workspaceID, content, out)
  mirrors Prompt (per-workspace lock, 5-min timeout, EnsureReady).
- internal/service/acp_service.go: thin AcpService.Stream wrapper
  that maps acp.StreamEvent -> model.AcpStreamEvent.
- internal/model/acp.go: AcpStreamRequest, AcpStreamEvent DTOs.
- internal/api/acp_handler.go: stream WS handler (upgrade, read
  prompt, run Stream in a goroutine, write events, ping/pong, Cancel
  on client close).
- internal/api/router.go: register the new route.
- internal/acp/transport.go: dispatch notifications synchronously
  (vs. goroutine per notification) so chunks preserve order before
  the session/prompt response.
- internal/acp/client_test.go: TestClientStreamEmitsChunkAndComplete
  with a fake transport that drives a known sequence.
- internal/api/acp_handler_test.go: TestAcpStreamHandlerRoutes
  smoke test using a fake opencode acp script.

Existing POST /api/workspaces/:id/acp/prompt is unchanged.

E2E: prompt 'say hi in exactly 3 words' -> 3 chunk events
('Hi',' there','!') + 1 complete {stopReason: 'end_turn'}.

Conversation: 019f3680-200f-79b0-860b-43302e60d0ea
2026-07-06 16:43:17 +08:00
tao.chen 3d34de96bf refactor(shell): sync.RWMutex for sessions + sync.Map for exited (mirror process pkg)
Same shape as the process package refactor:
- LocalManager.mu (sync.Mutex) -> sessionsMu (sync.RWMutex).
  Read paths (Status, Subscribe, Stdin, ExitStatus, Resize) take
  RLock; write paths (Start, Stop, Restart, List) take Lock.
- LocalManager.exited: was a hand-rolled map[workspaceID]map[shellID]struct{}
  guarded by exitedMu; now a sync.Map keyed by shellID only (UUID
  is globally unique, no need for the nested map). Helpers
  IsExited / MarkAsExited / ClearExited.
- shellOrder stays a plain map; read+written under sessionsMu.
- waitExit remains the sole caller of MarkAsExited; Start /
  Stop / Restart call ClearExited.
- New TestShellIsExitedHelpers covers the helper semantics.

go test -race -count=2 ./... clean. Same caveat as the process
package: at this app's concurrency level, neither sync.RWMutex nor
sync.Map measurably beats the previous pair — the change is mostly
stylistic (one fewer lock, no nested maps, more idiomatic Go).

Conversation: 019f3673-d2d5-78f0-a7a9-5e3e91b65933
2026-07-06 16:14:55 +08:00
tao.chen 5e694aec12 refactor(process): sync.RWMutex for sessions + sync.Map for exited
Apply the suggested optimization on top of the previous race fix.

- internal/process/manager.go: LocalManager.sessionsMu is now an
  RWMutex. Read paths (Status, Subscribe lookup, Stdin lookup,
  ExitStatus lookup) take RLock; mutating paths (Start, Stop,
  Restart) take Lock. exited is now a sync.Map instead of a
  hand-rolled map+mutex; reads (IsExited) are lock-free, writes
  (MarkAsExited from waitExit, ClearExited on Start/Stop/Restart)
  are atomic. Added IsExited / MarkAsExited / ClearExited helpers.

No behavior change. The previous race fix (the only one in the
package) is preserved: waitExit remains the sole caller of
sess.Cmd.Wait(), and all other code paths read the exited marker
rather than cmd.ProcessState.

- ACP: nothing to do. The ACP service talks to opencode through
  process.Manager; it never reads cmd.ProcessState directly. The
  process package race fix already covers the ACP code path.
  Verified: go test -race -count=2 ./... clean across the repo.

Note: at this app's concurrency level (tens of workspaces, accessed
occasionally), neither sync.RWMutex nor sync.Map measurably beats
the previous map+mutex pair — the critical sections are O(1)
lookups with no contention. The change is mostly stylistic
(removes one lock, fewer deadlock surfaces, more idiomatic Go).

E2E: start / status(running) / duplicate(409) / stop / status(stopped)
/ restart / status(running) / stop all behave correctly.
2026-07-06 15:59:36 +08:00
tao.chen 568423751f fix(process): remove data race on cmd.ProcessState (was read across goroutines)
The race: os/exec writes cmd.ProcessState inside cmd.Wait(), which
runs in the waitExit goroutine. Start, Status, Subscribe, and
Restart all read cmd.ProcessState from other goroutines to decide
'still running?'. That's a textbook Go data race — fixed by
TestStartRejectsDuplicateRunningSession under -race.

Fix: port the same pattern the shell package uses for its multi-shell
refactor. The manager now owns an exited map[string]struct{} guarded
by exitedMu. waitExit is the ONLY place that calls cmd.Wait(); after
Wait() returns it records exited[workspaceID]. Everyone else checks
the exited map instead of reading cmd.ProcessState.

- internal/process/manager.go: add exited + exitedMu to LocalManager;
  Start conflict check, Status, Subscribe, Restart, Stop all consult
  the exited map; Start clears the entry on new process; Stop and
  Restart delete the entry on stop; waitExit sets it after Wait().
- go.mod / go.sum: tidied (uuid v1.6.0 was already direct dep from
  the shell refactor).

go test -race -count=3 ./...  clean across the whole repo.
go test -race -count=5 ./internal/process/...  clean.
2026-07-06 15:52:07 +08:00
tao.chen 7b40b23d33 feat(web): multi-terminal tabs per workspace
Frontend wiring for the new multi-shell backend. The bottom panel's
Terminal tab now has a tab strip: one tab per shell (showing
shellId[:8] + an X to close), plus a + button to spawn a new shell.
Tab state lives in workspace-ui-store (shellsByWorkspace +
activeShellIdByWorkspace). Auto-start creates the first shell if the
list is empty.

- web/src/lib/api/process.ts:
  - useShellStart returns ShellInfo on 201
  - useShellStop / useShellRestart take {shellId}
  - useShellStatus(workspaceId, shellId) requires both
  - useShellWebSocket(workspaceId, shellId, enabled) refactored:
    shellId change closes the current socket intentionally and opens
    a new one with ?shellId= appended
  - useShellResize takes {shellId, cols, rows}
  - new useShellList(workspaceId)
- web/src/lib/store/workspace-ui-store.ts: shellsByWorkspace +
  activeShellIdByWorkspace maps + setters (addShellToWorkspace,
  removeShellFromWorkspace, setActiveShell, setShellsForWorkspace)
- web/src/components/terminal/TerminalPanel.tsx: requires shellId
  prop; clears/resets terminal on shellId change (in addition to
  workspaceId); welcome banner shows
  '[connected to <workspaceId>/<shortShellId>]'
- web/src/components/layout/WorkspaceShell.tsx: new TerminalTabs
  component:
  - lists shells as tabs, + to add, x to close
  - useShellList hydrates the store on mount
  - auto-start guard via useRef so we don't double-fire
  - close-pending tracked per shellId via Set
  - renders the active <TerminalPanel workspaceId shellId>

Diff: 4 files. pnpm lint + pnpm build clean.

Conversation: B975103F-4528-493E-8F8A-91D20506631D
2026-07-06 15:48:12 +08:00
tao.chen 13b0c4e53f feat(shell): multi-shell per workspace + fix WS disconnect killing bash
BREAKING: every shell operation now requires a shellId. The Manager
previously keyed by workspaceID alone (one bash per workspace). It now
keys by (workspaceID, shellID) where shellID is a UUID returned by
Start.

Fixes the long-standing bug where the WS handler's closeAll called
stdin.Close() and killed bash when a WS client disconnected. The
Session owns the pty file; the WS handler no longer closes it. The
pty is only closed by Manager.Stop (explicit) or by captureOutput
when the process naturally exits (EOF).

- internal/shell/manager.go: Manager interface gains List and every
  method takes shellID; storage becomes
  map[workspaceID]map[shellID]*Session; Start returns (shellID, err)
  via uuid.NewString; Resize/Status/ExitStatus/Subscribe/Stdin/Stop
  route by shellID; new List(workspaceID) returns ShellInfo[] in
  creation order.
- internal/shell/session.go: Session gains ShellID + CreatedAt; Status
  type gains ShellID.
- internal/shell/manager_test.go: updated existing tests for new
  signatures; added TestShellMultiInstance (two shells in one
  workspace, no output cross-talk, independent stop, List behavior).
- internal/service/shell_service.go: wrappers carry shellID; new
  List method.
- internal/service/workspace_service.go: auto-start captures/logs
  shellID; Delete iterates and stops all workspace shells.
- internal/api/shell_handler.go: WS closeAll drops stdin.Close();
  start/restart return 201 with {shellId, pid, ...}; new list handler;
  stop/resize take shellId in body.
- internal/api/router.go: GET /api/workspaces/:id/shell (list).
- internal/model/shell.go: new ShellStartResponse, ShellInfo,
  ShellListResponse, ShellStopRequest, ShellRestartRequest; updated
  ShellStatusResponse + ShellResizeRequest to carry shellId.
- go.mod/go.sum: github.com/google/uuid.

E2E:
- workspace create -> 1 auto shell
- start 2 more -> 3 shells in list
- stop 1 -> 2 shells in list
- WS connect -> send cmd -> disconnect -> WS reconnect -> send cmd ->
  response OK, no [process exited] banner
2026-07-06 14:57:03 +08:00
tao.chen f5a6ff8b0d feat(shell): PTY resize via HTTP + frontend xterm.onResize hook
The PTY was hardcoded to 80x24 at start, so full-screen programs
(htop, vim, less, tmux, top) drew themselves for 80x24 regardless
of the actual xterm window. Add a path for the frontend to push the
real cols/rows to the backend, which calls pty.Setsize on the pty
file.

Backend:
- internal/shell/manager.go: Resize(workspaceID, cols, rows) added to
  the Manager interface and implemented on LocalManager. Looks up the
  session, type-asserts sess.Stdin.(*os.File), calls pty.Setsize.
  Validates cols/rows in [1, 10000] and returns CodeBadRequest on
  bad input, CodeNotFound when no session.
- internal/service/shell_service.go: ShellService.Resize wrapper that
  checks workspace existence first.
- internal/api/shell_handler.go: resize handler, 204 on success.
- internal/api/router.go: register POST /workspaces/:id/shell/resize.
- internal/model/shell.go: ShellResizeRequest{Cols, Rows}.
- internal/shell/manager_test.go: 3 new tests (valid resize via
  pty.Getsize round-trip, invalid size, missing session).

Frontend:
- web/src/lib/api/process.ts: useShellResize mutation hook.
- web/src/components/terminal/TerminalPanel.tsx: terminal.onResize
  subscription with 100ms debounce; filters 0x0; only fires when
  workspaceId is set; cleanup clears timeout + disposes listener.

E2E: 'stty size' after POST /shell/resize {cols:200, rows:50} returns
'50 200'. 0x40 → 400, missing workspace → 404, valid → 204.

Conversation: 019f360a-5eba-7c81-94d3-e5d58ad3c026
2026-07-06 14:16:09 +08:00
tao.chen 5ed618494d feat(shell): run bash on a real PTY via creack/pty
The shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran
without a tty. Programs that check isatty() (python, htop, less,
vim-style REPLs) would drop echo, ignore line-editing, and leak literal
0x1b bytes into the program — Python's REPL reported 'invalid
non-printable character U+001B' on input.

Switch to github.com/creack/pty:StartWithSize with an 80x24 default
and TERM=xterm-256color in cmd.Env. The PTY file replaces both the
stdin and stdout pipes; sess.Stdin is the pty *os.File and the
output capture goroutine reads from it directly. waitExit no longer
needs to close sess.Stdin — the capture goroutine's defer handles it
when the process dies and the pty returns EOF.

- go.mod / go.sum: add creack/pty v1.1.24.
- internal/shell/manager.go: rewrite Start to use pty.StartWithSize;
  captureOutput reads from the pty; waitExit simplified; race-clean
  exited map to avoid the cmd.ProcessState data race.
- internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal
  (echo with echo + output interleaved proves PTY echo is on) and
  TestShellPTYTermEnv (TERM=xterm-256color propagates).

E2E against the real bash: 'python3 -q' returns the >>> prompt,
print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak.

Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
2026-07-06 14:05:47 +08:00
tao.chen 789f62669c fix(acp): group agent chunks by messageId (backend) and coalesce consecutive same-role entries in the UI (frontend)
The opencode acp binary streams agent_message_chunk notifications with
an EMPTY messageId. The backend was already correct (it groups when a
messageId is present), but the UI rendered every chunk as its own
bubble, so a single 'count 1 to 5' reply showed as 9 separate bubbles.

Backend:
- internal/acp/history.go: add MessageID to Message; new AppendOrCreate
  method that finds or creates an entry by messageId and appends text
  to it (preserving the original timestamp).
- internal/acp/client.go: agent_message_chunk and user_message_chunk
  use AppendOrCreate.
- internal/model/acp.go: expose MessageID in the wire response.
- internal/service/acp_service.go: copy MessageID into the API DTO.
- internal/acp/client_test.go: add TestHistoryAppendOrCreateGroupsByMessageID.

Frontend:
- web/src/components/acp/groupMessages.ts: new pure helper that
  coalesces consecutive same-role history entries into a single bubble
  (keeps the first chunk's time + messageId).
- web/src/components/acp/AcpPanel.tsx: render coalesced messages;
  auto-scroll effect now depends on coalesced so it fires as the
  agent streams.

Verified via e2e: 'count from 1 to 5' now returns a single agent entry
with text '1\n2\n3\n4\n5' instead of 9 separate entries.
2026-07-06 13:48:56 +08:00
tao.chen 041c3dc002 fix(web): clear terminal on workspace change; disable Mac Option as Meta
- terminal: on workspaceId change call terminal.clear() and
  terminal.reset() so the previous workspace's scrollback is wiped
  before the new shell's output appears.
- terminal: set macOptionIsMeta: false. With the default (true),
  Mac Option+key sequences (e.g. Option+[ emits ESC) leak a literal
  0x1b into the shell's stdin, which crashes downstream REPLs like
  Python's with "invalid non-printable character U+001B".
2026-07-06 13:39:20 +08:00
tao.chen 90f6f877b5 docs: mark opencode ACP panel integration as completed in plan.md 2026-07-06 11:52:44 +08:00
tao.chen eb2bf3e684 fix(acp): parse session/update content as ContentBlock, not string
The ACP spec defines session/update content as a ContentBlock object
({type, text}) but we decoded it as a string. This dropped every
agent_message_chunk and left prompt responses empty. E2E against the
real opencode acp binary surfaced the bug.

- internal/acp/messages.go: add ContentBlock struct; Update.Content is
  now ContentBlock.
- internal/acp/client.go: handleNotification extracts Text when
  Content.Type == "text"; other types are logged at debug and dropped.
- internal/acp/client_test.go: NDJSON framing test sends content as a
  ContentBlock object and reads .Text.
- README.md: document /acp/* routes + CODESPACE_OPENCODE_ARGS env.

Verified end-to-end: POST /acp/prompt returns text="Hi there!", 4
messages in history (1 user + 3 agent chunks), 0 invalid session/update
log entries.

Conversation: 019f357d-4236-7010-949c-e61067618d42
2026-07-06 11:52:22 +08:00
tao.chen a633af272b feat(web): add ACP chat panel with tabs (Terminal | Agent)
- web: add react-markdown + remark-gfm + react-syntax-highlighter
- web: new lib/api/acp.ts (useAcpStatus/History/Prompt/Cancel)
- web: new components/acp/ (AcpPanel + MessageBubble + MarkdownView +
  ThinkingIndicator). Markdown rendering with code highlighting via
  Prism oneDark. Auto-scroll to bottom when near it; error toasts
  fade after 5s.
- web: WorkspaceShell now renders a tabbed bottom panel (Terminal |
  Agent) when the bottom panel is visible. Tab state in
  workspace-ui-store.
- web: StatusBar shows agent status (ready / initializing / error)
  when the ACP session is initialized.
- web: pnpm-workspace.yaml: add packages ['.'] so pnpm install works
  in this workspace.

Conversation: 019f3562-9382-7e00-ad92-3162341e9274
2026-07-06 11:30:43 +08:00
tao.chen 2597d0d60c feat(backend): add opencode ACP (Agent Client Protocol) client
Adds a minimal but real ACP stack for the opencode process:

- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
  process stdio. Implements initialize / session/new / session/prompt /
  session/cancel. Serves fs/read_text_file and fs/write_text_file from the
  workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
  process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
  POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
  notification dispatch, agent-initiated request handling (fs + terminal).

Existing process WS endpoint and Shell subsystem are unchanged.

Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19
2026-07-06 11:03:59 +08:00
tao.chen 18cb3a0151 feat(web): replace workspace native select with shadcn select 2026-07-03 18:48:01 +08:00
tao.chenandClaude c065079d87 feat(web): migrate prompts and confirms to shadcn dialog and dropdown
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:38:28 +08:00
tao.chenandClaude 84fa86c490 docs: add plan for opencode acp panel and shadcn migration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:29:12 +08:00
tao.chenandClaude 3c6e0c3121 fix(web): move save logic from monaco to react with manual and auto-save
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:25:47 +08:00
tao.chenandClaude 7a6e7e7ebb feat(web): replace hover buttons with right-click context menu in file tree
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 17:40:07 +08:00
tao.chenandClaude fcb678773c feat(web): suppress browser ctrl+s and add file tree CRUD
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 17:17:55 +08:00
tao.chenandClaude ac87454711 feat(web): add file tree manual refresh + 5s polling + focus refetch
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:57:20 +08:00
tao.chenandClaude 53fe249dd6 feat(web): wire terminal panel to workspace shell ws
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:48:49 +08:00
tao.chenandClaude 0ec3efa812 feat(shell): add parallel bash shell subsystem auto-started per workspace
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:36:35 +08:00
tao.chenandClaude 75e807c154 fix(web): only connect terminal ws when process is running
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:15:51 +08:00
tao.chenandClaude 11ac0299a6 fix(web): treat server clean close as session end without reconnect
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:07:46 +08:00
tao.chenandClaude 60524bad0e fix(web): show toolbar and status bar when no workspace selected
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 16:00:58 +08:00
tao.chenandClaude 61e5ede650 fix(workspace): tolerate read-only files on delete
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:50:37 +08:00
tao.chenandClaude 09150c2a7d fix(web): enable websocket proxying in vite dev server
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:47:43 +08:00
tao.chenandClaude 09241b3350 test: cover multi-subscriber fanout for process ws
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:20:31 +08:00
tao.chenandClaude 0cfe8955a4 docs: drop ring buffer mentions from ws spec and plan
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:12:35 +08:00
tao.chenandClaude 4a4028486c refactor(process): use map for subscribers and drop dead ring buffer
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:08:20 +08:00
tao.chenandClaude 03377eec3c docs: document terminal websocket endpoint
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:36:35 +08:00
tao.chenandClaude d8c8c7fc83 feat(web): wire terminal panel to live process websocket
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:36:12 +08:00
tao.chenandClaude fa5b53939d feat(web): add useProcessWebSocket hook
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:28:42 +08:00
tao.chenandClaude 7cd05ba98a feat(api): expose process output over websocket
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:23:48 +08:00
tao.chenandClaude 11ccdda579 feat(service): expose subscribe, input, exit status for ws handler
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:10:50 +08:00
tao.chenandClaude 13a5aeda2c feat(process): capture stdout/stderr and fan out to subscribers
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:05:40 +08:00
tao.chenandClaude 33fac266c7 docs: add terminal websocket implementation plan
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:54:55 +08:00
tao.chenandClaude 741ff63424 docs: add terminal websocket design spec
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:48:13 +08:00
tao.chenandClaude a7be9d3f0c feat: wire OpenCode process control UI
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:46:13 +08:00
tao.chenandClaude 58aa6077d8 feat: wire Monaco editor to real file read/write
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:27:19 +08:00
tao.chenandClaude d587c94f50 feat: wire file explorer to real backend API
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:19:30 +08:00
tao.chenandClaude 867f1d204e feat: wire workspace CRUD UI to backend
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:05:56 +08:00
tao.chenandClaude b3d0b63f4b fix: defer xterm fit to ResizeObserver ticks
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:03:05 +08:00
tao.chen 70799a5df8 Merge branch 'feat/web'
# Conflicts:
#	README.md
2026-07-02 19:53:00 +08:00
tao.chenandClaude b5fababeea docs: add web frontend usage
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 19:38:19 +08:00
tao.chenandClaude 062cde88f8 feat: add web editor and terminal panels
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 18:35:17 +08:00
tao.chenandClaude 0f634981b5 feat: add resizable workspace shell
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 18:15:52 +08:00
tao.chen ef26b9c154 Merge pull request 'chore: configure gin mode' (#3) from feat/gin-mode-config into main
Reviewed-on: https://gitea-production-a772.up.railway.app/taochen/codespace/pulls/3
2026-07-02 09:47:58 +00:00
tao.chenandClaude Fable 5 ab4f905725 chore: configure gin mode
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:46:04 +08:00