diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index a6e2e1e366..63b4b2a046 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -13,9 +13,10 @@ import ( ) // registerE2EResetRoutes registers the E2E data-reset endpoint. -// The endpoint is only available when KANDEV_MOCK_AGENT=true (i.e., during E2E tests). +// The endpoint is available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). func registerE2EResetRoutes(router *gin.Engine, repo *sqliterepo.Repository, log *logger.Logger) { - if os.Getenv("KANDEV_MOCK_AGENT") != "true" { + mockMode := os.Getenv("KANDEV_MOCK_AGENT") + if mockMode != "true" && mockMode != "only" { return } diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index c99fc6c788..84d1a4a0f1 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -10,15 +10,25 @@ import ( ) // Provide creates and loads the agent registry. +// +// KANDEV_MOCK_AGENT controls mock-agent availability: +// - "only" → E2E mode: only register mock agent, skip all others +// - "true" → Dev mode: load all agents AND enable mock agent +// - unset → Production: load all agents, mock agent disabled func Provide(log *logger.Logger) (*Registry, func() error, error) { reg := NewRegistry(log) - if os.Getenv("KANDEV_MOCK_AGENT") == "true" { - // Only register mock agent — skip slow agent discovery for all others + mockMode := os.Getenv("KANDEV_MOCK_AGENT") + if mockMode == "only" { + // E2E mode: only register mock agent — skip agent discovery for all others _ = reg.Register(agents.NewMockAgent()) configureMockAgent(reg, log) } else { reg.LoadDefaults() + if mockMode == "true" { + // Dev mode: enable mock agent alongside all other agents + configureMockAgent(reg, log) + } } return reg, func() error { return nil }, nil diff --git a/apps/backend/internal/agent/registry/provider_test.go b/apps/backend/internal/agent/registry/provider_test.go new file mode 100644 index 0000000000..2653a15906 --- /dev/null +++ b/apps/backend/internal/agent/registry/provider_test.go @@ -0,0 +1,82 @@ +package registry + +import ( + "testing" +) + +func TestProvide_MockAgentModes(t *testing.T) { + tests := []struct { + name string + envValue string + wantMockEnabled bool + wantOnlyMock bool // only mock-agent registered (no other defaults) + wantDefaultsCount int // minimum expected agent count (0 = don't check) + }{ + { + name: "unset: all defaults loaded, mock disabled", + envValue: "", + wantMockEnabled: false, + wantOnlyMock: false, + wantDefaultsCount: 2, // at least auggie + mock-agent + }, + { + name: "true: all defaults loaded, mock enabled", + envValue: "true", + wantMockEnabled: true, + wantOnlyMock: false, + }, + { + name: "only: only mock-agent registered and enabled", + envValue: "only", + wantMockEnabled: true, + wantOnlyMock: true, + }, + { + name: "arbitrary value: treated as unset", + envValue: "false", + wantMockEnabled: false, + wantOnlyMock: false, + wantDefaultsCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("KANDEV_MOCK_AGENT", tt.envValue) + + log := newTestLogger() + reg, cleanup, err := Provide(log) + if err != nil { + t.Fatalf("Provide() error: %v", err) + } + defer cleanup() //nolint:errcheck + + // Check mock-agent presence and enabled state + mock, hasMock := reg.Get("mock-agent") + if !hasMock { + t.Fatal("mock-agent should always be registered") + } + if mock.Enabled() != tt.wantMockEnabled { + t.Errorf("mock-agent Enabled() = %v, want %v", mock.Enabled(), tt.wantMockEnabled) + } + + // Check agent count + all := reg.List() + if tt.wantOnlyMock { + if len(all) != 1 { + t.Errorf("only mode: expected 1 agent, got %d", len(all)) + } + } + if tt.wantDefaultsCount > 0 && len(all) < tt.wantDefaultsCount { + t.Errorf("expected at least %d agents, got %d", tt.wantDefaultsCount, len(all)) + } + + // In non-only mode, verify other default agents exist + if !tt.wantOnlyMock { + if !reg.Exists("auggie") { + t.Error("expected default agent 'auggie' to be loaded") + } + } + }) + } +} diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 6c41e517f2..4782fcb74e 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -240,7 +240,7 @@ type ghReview struct { } `json:"user"` State string `json:"state"` Body string `json:"body"` - SubmittedAt time.Time `json:"submittedAt"` + SubmittedAt time.Time `json:"submitted_at"` } func (c *GHClient) ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) { diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index f0d1b1f367..2e56f96388 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -23,10 +23,10 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr const ports = await pickPorts(backendPort, webPort); const dbPath = path.join(repoRoot, "apps", "backend", "kandev.db"); - const extra: Record = { KANDEV_DATABASE_PATH: dbPath }; - if (process.env.KANDEV_MOCK_AGENT) { - extra.KANDEV_MOCK_AGENT = process.env.KANDEV_MOCK_AGENT; - } + const extra: Record = { + KANDEV_DATABASE_PATH: dbPath, + KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true", + }; const backendEnv = buildBackendEnv({ ports, extra }); const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); const logLevel = diff --git a/apps/packages/ui/src/dialog.tsx b/apps/packages/ui/src/dialog.tsx index 4b425fd85b..10b41906f1 100644 --- a/apps/packages/ui/src/dialog.tsx +++ b/apps/packages/ui/src/dialog.tsx @@ -8,6 +8,20 @@ import { Button } from "./button"; import { IconX } from "@tabler/icons-react"; function Dialog({ ...props }: React.ComponentProps) { + const openRef = React.useRef(props.open); + openRef.current = props.open; + + React.useEffect(() => { + return () => { + // Safety cleanup: Radix Dialog sets pointer-events: none on body when + // modal. If the dialog unmounts while open (e.g. page navigation), + // Radix never cleans up, leaving the page unclickable. + if (openRef.current) { + document.body.style.removeProperty("pointer-events"); + } + }; + }, []); + return ; } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index b22e42d24c..48156dfb60 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -394,7 +394,7 @@ importers: version: 0.55.1 next: specifier: 16.1.5 - version: 16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -422,6 +422,12 @@ importers: recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -442,7 +448,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(react@19.2.3) + version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -4110,6 +4116,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4602,15 +4612,33 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} @@ -5608,6 +5636,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -5947,6 +5978,12 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} @@ -6546,6 +6583,9 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -6639,6 +6679,9 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -10435,6 +10478,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -10610,8 +10655,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10633,7 +10678,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10644,22 +10689,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10670,7 +10715,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11162,6 +11207,43 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -11196,10 +11278,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + headers-polyfill@4.0.3: {} hermes-estree@0.25.1: {} @@ -12168,7 +12268,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.5 '@swc/helpers': 0.5.15 @@ -12177,7 +12277,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.5 '@next/swc-darwin-x64': 16.1.5 @@ -12365,6 +12465,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -12810,6 +12914,17 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-breaks@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -13297,10 +13412,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 + optionalDependencies: + '@babel/core': 7.28.6 stylis@4.3.6: {} @@ -13596,6 +13713,11 @@ snapshots: - '@types/react' - '@types/react-dom' + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -13705,6 +13827,8 @@ snapshots: w3c-keyname@2.2.8: {} + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} whatwg-mimetype@3.0.0: {} diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index d26f17e161..e2f37fc8da 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -9,13 +9,13 @@ import { IconAlertTriangle, IconGitMerge, IconCheck, + IconLoader2, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { Spinner } from "@kandev/ui/spinner"; import { useAppStore } from "@/components/state-provider"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { prPanelLabel } from "@/components/github/pr-utils"; @@ -165,7 +165,8 @@ function ApproveButton({ const { toast } = useToast(); const [submitting, setSubmitting] = useState(false); - if (taskPR.state !== "open") return null; + const liveState = feedback?.pr.state ?? taskPR.state; + if (liveState !== "open") return null; const alreadyApproved = feedback?.reviews.some( (r) => r.state === "APPROVED" && r.author === feedback.pr.author_login, @@ -206,6 +207,38 @@ function ApproveButton({ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) { const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number); const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number); + const setTaskPR = useAppStore((s) => s.setTaskPR); + + // Sync live feedback data back to the store so topbar/other consumers stay up to date. + // Use primitive deps to avoid re-render loops from object reference changes. + const prState = taskPR.state; + const prMergedAt = taskPR.merged_at ?? null; + const prClosedAt = taskPR.closed_at ?? null; + const prAdditions = taskPR.additions; + const prDeletions = taskPR.deletions; + const prTaskId = taskPR.task_id; + useEffect(() => { + if (!feedback) return; + const livePR = feedback.pr; + if ( + livePR.state !== prState || + (livePR.merged_at ?? null) !== prMergedAt || + (livePR.closed_at ?? null) !== prClosedAt || + livePR.additions !== prAdditions || + livePR.deletions !== prDeletions + ) { + setTaskPR(prTaskId, { + ...taskPR, + state: livePR.state, + additions: livePR.additions, + deletions: livePR.deletions, + merged_at: livePR.merged_at, + closed_at: livePR.closed_at, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [feedback, prState, prMergedAt, prClosedAt, prAdditions, prDeletions, prTaskId, setTaskPR]); + const metrics = derivePanelMetrics(taskPR, feedback); return ( @@ -222,7 +255,7 @@ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: str
{loading && !feedback && (
- +
)} {feedback && ( @@ -385,9 +418,10 @@ function PRHeader({ loading: boolean; onRefresh: () => void; }) { + const liveState = feedback?.pr.state ?? taskPR.state; const isDraft = feedback?.pr.draft ?? false; const isMergeable = feedback?.pr.mergeable ?? true; - const showWarnings = isDraft || (!isMergeable && taskPR.state === "open"); + const showWarnings = isDraft || (!isMergeable && liveState === "open"); return (
@@ -398,7 +432,7 @@ function PRHeader({
- + #{taskPR.pr_number} {taskPR.head_branch} @@ -418,7 +452,7 @@ function PRHeader({ Draft )} - {!isMergeable && taskPR.state === "open" && ( + {!isMergeable && liveState === "open" && ( Not mergeable diff --git a/apps/web/components/github/pr-shared.tsx b/apps/web/components/github/pr-shared.tsx index 757db8eb8d..a6448bf0ff 100644 --- a/apps/web/components/github/pr-shared.tsx +++ b/apps/web/components/github/pr-shared.tsx @@ -1,19 +1,27 @@ import { useState } from "react"; import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; export function formatTimeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); + const date = new Date(dateStr); + if (isNaN(date.getTime())) return ""; + const diff = Date.now() - date.getTime(); + if (diff < 0) return "just now"; const minutes = Math.floor(diff / 60000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); - return `${days}d ago`; + if (days < 7) return `${days}d ago`; + if (days < 30) return `${Math.floor(days / 7)}w ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; } function formatMs(ms: number): string { @@ -153,10 +161,20 @@ export function AddToContextButton({ ); } +/** Sanitization schema: default safe tags + details/summary for collapsible sections. */ +const sanitizeSchema = { + ...defaultSchema, + tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary"], +}; + export function PRMarkdownBody({ body }: { body: string }) { return (
- + {body}
diff --git a/apps/web/components/github/pr-topbar-button.tsx b/apps/web/components/github/pr-topbar-button.tsx index c51533b047..959a8fd414 100644 --- a/apps/web/components/github/pr-topbar-button.tsx +++ b/apps/web/components/github/pr-topbar-button.tsx @@ -16,6 +16,14 @@ function prIconColor(state: string): string { } function PRStatusIcon({ pr }: { pr: TaskPR }) { + // Terminal states take priority + if (pr.state === "merged") { + return ; + } + if (pr.state === "closed") { + return ; + } + // Review/check states only matter for open PRs if (pr.checks_state === "failure" || pr.review_state === "changes_requested") { return ; } @@ -25,12 +33,6 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) { if (pr.checks_state === "pending" || pr.review_state === "pending") { return ; } - if (pr.state === "merged") { - return ; - } - if (pr.state === "closed") { - return ; - } return null; } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index ebff394a4e..e5d24fbce5 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -300,8 +300,8 @@ export const TaskFormInputs = memo(function TaskFormInputs({ rows={2} className={ isSessionMode - ? "min-h-[120px] max-h-[240px] resize-none overflow-auto" - : "min-h-[96px] max-h-[240px] resize-y overflow-auto" + ? "min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]" + : "min-h-[96px] max-h-[240px] resize-y overflow-auto text-[13px]" } required={isSessionMode} disabled={disabled} diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 827ebbf6e1..c446953a0c 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -167,10 +167,9 @@ export function useTaskSubmitHandlers({ const response = await launchSession(request); const newSessionId = response?.session_id; + onOpenChange(false); if (newSessionId) { router.push(linkToSession(newSessionId)); - } else { - onOpenChange(false); } } catch (error) { toast({ @@ -299,8 +298,8 @@ export function useTaskSubmitHandlers({ ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); + onOpenChange(false); if (planMode && newSessionId) { - onOpenChange(false); activatePlanMode({ sessionId: newSessionId, taskId: taskResponse.id, @@ -310,8 +309,6 @@ export function useTaskSubmitHandlers({ }); } else if (isPassthroughProfile && newSessionId) { router.push(linkToSession(newSessionId)); - } else { - onOpenChange(false); } }, [ @@ -348,6 +345,7 @@ export function useTaskSubmitHandlers({ ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId }); + onOpenChange(false); if (newSessionId) { activatePlanMode({ sessionId: newSessionId, @@ -356,8 +354,6 @@ export function useTaskSubmitHandlers({ setPlanMode, router, }); - } else { - onOpenChange(false); } }, [ diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index f171e0120d..60c07bbcf9 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -8,6 +8,7 @@ import { IconGitCherryPick, IconGitMerge, IconArrowRight, + IconLoader2, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; @@ -60,14 +61,19 @@ function BranchHoverCard({ function PullDropdown({ behindCount, isLoading, + loadingOperation, onPull, onRebase, }: { behindCount: number; isLoading: boolean; + loadingOperation: string | null; onPull: () => void; onRebase: () => void; }) { + const isPulling = loadingOperation === "pull"; + const isRebasing = loadingOperation === "rebase"; + const isPullRelated = isPulling || isRebasing; return ( @@ -77,10 +83,18 @@ function PullDropdown({ className="h-5 text-[11px] px-1.5 gap-1 cursor-pointer" disabled={isLoading} > - - Pull - {behindCount > 0 && {behindCount}} - + {isPullRelated ? ( + + ) : ( + + )} + {isPulling && "Pulling…"} + {!isPulling && isRebasing && "Rebasing…"} + {!isPulling && !isRebasing && "Pull"} + {behindCount > 0 && !isPullRelated && ( + {behindCount} + )} + {!isPullRelated && } @@ -111,6 +125,7 @@ export function ChangesPanelHeader({ baseBranchDisplay, behindCount, isLoading, + loadingOperation, onOpenDiffAll, onOpenReview, onPull, @@ -123,6 +138,7 @@ export function ChangesPanelHeader({ baseBranchDisplay: string; behindCount: number; isLoading: boolean; + loadingOperation: string | null; onOpenDiffAll?: () => void; onOpenReview?: () => void; onPull: () => void; @@ -163,6 +179,7 @@ export function ChangesPanelHeader({ diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index a5a043698b..fa2c7848c5 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -216,6 +216,7 @@ function useChangesPRHandlers( toast: Toast, taskTitle: string | undefined, baseBranch: string | undefined, + firstCommitMessage: string | undefined, ) { const [prDialogOpen, setPrDialogOpen] = useState(false); const [prTitle, setPrTitle] = useState(""); @@ -223,10 +224,10 @@ function useChangesPRHandlers( const [prDraft, setPrDraft] = useState(true); const handleOpenPRDialog = useCallback(() => { - setPrTitle(taskTitle || ""); + setPrTitle(firstCommitMessage || taskTitle || ""); setPrBody(""); setPrDialogOpen(true); - }, [taskTitle]); + }, [firstCommitMessage, taskTitle]); const handleCreatePR = useCallback(async () => { if (!prTitle.trim()) return; setPrDialogOpen(false); @@ -275,11 +276,11 @@ export function useChangesDialogHandlers( gitOps: GitOps, toast: Toast, handleGitOperation: GitOperationFn, - taskTitle: string | undefined, - baseBranch: string | undefined, + prDefaults: { taskTitle?: string; baseBranch?: string; firstCommitMessage?: string }, ) { + const { taskTitle, baseBranch, firstCommitMessage } = prDefaults; const discardCommit = useChangesDiscardCommitHandlers(gitOps, toast, handleGitOperation); const reset = useChangesResetHandlers(gitOps, handleGitOperation); - const pr = useChangesPRHandlers(gitOps, toast, taskTitle, baseBranch); + const pr = useChangesPRHandlers(gitOps, toast, taskTitle, baseBranch, firstCommitMessage); return { ...discardCommit, ...reset, ...pr }; } diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index 340f02c2f1..7824d3d398 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -5,6 +5,7 @@ import { IconGitPullRequest, IconCloudUpload, IconChevronDown, + IconLoader2, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -141,6 +142,7 @@ type ActionButtonsSectionProps = { onPush: () => void; onForcePush: () => void; isLoading: boolean; + loadingOperation: string | null; aheadCount: number; canPush: boolean; canCreatePR: boolean; @@ -152,14 +154,17 @@ export function ActionButtonsSection({ onPush, onForcePush, isLoading, + loadingOperation, aheadCount, canPush, canCreatePR, existingPrUrl, }: ActionButtonsSectionProps) { const prExists = !!existingPrUrl; - const createPrDisabled = !canCreatePR || prExists; + const createPrDisabled = !canCreatePR || prExists || isLoading; const pushDisabled = !canPush || isLoading; + const isPushing = loadingOperation === "push"; + const isCreatingPR = loadingOperation === "create_pr"; let pushTooltip: string | null = null; if (isLoading) pushTooltip = "A git operation is in progress"; else if (!canPush) pushTooltip = "No commits ahead of remote"; @@ -176,7 +181,11 @@ export function ActionButtonsSection({ onClick={onOpenPRDialog} disabled={createPrDisabled} > - + {isCreatingPR ? ( + + ) : ( + + )} Create PR
@@ -193,9 +202,13 @@ export function ActionButtonsSection({ onClick={onPush} disabled={pushDisabled} > - + {isPushing ? ( + + ) : ( + + )} Push - {aheadCount > 0 && ( + {aheadCount > 0 && !isPushing && ( {aheadCount} ahead )} @@ -234,7 +247,11 @@ type FileListSectionProps = { pendingStageFiles: Set; isLast: boolean; actionLabel: string; + isActionLoading?: boolean; onAction: () => void; + secondaryActionLabel?: string; + isSecondaryActionLoading?: boolean; + onSecondaryAction?: () => void; onOpenDiff: (path: string) => void; onEditFile: (path: string) => void; onStage: (path: string) => void; @@ -248,7 +265,11 @@ export function FileListSection({ pendingStageFiles, isLast, actionLabel, + isActionLoading, onAction, + secondaryActionLabel, + isSecondaryActionLoading, + onSecondaryAction, onOpenDiff, onEditFile, onStage, @@ -277,15 +298,29 @@ export function FileListSection({ )} {files.length > 0 && ( -
+
+ {onSecondaryAction && secondaryActionLabel && ( + + )}
)} diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 72bec7822f..d34e238f57 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -214,6 +214,7 @@ type ChangesPanelBodyProps = { totalFileCount: number; aheadCount: number; isLoading: boolean; + loadingOperation: string | null; dialogs: ReturnType; onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; @@ -221,6 +222,7 @@ type ChangesPanelBodyProps = { onOpenReview?: () => void; onRevertCommit?: (sha: string) => void; onStageAll: () => void; + onUnstageAll: () => void; onStage: (path: string) => Promise; onUnstage: (path: string) => Promise; onPush: () => void; @@ -325,12 +327,14 @@ type TimelineProps = Pick< | "pendingStageFiles" | "aheadCount" | "isLoading" + | "loadingOperation" | "dialogs" | "onOpenDiffFile" | "onEditFile" | "onOpenCommitDetail" | "onRevertCommit" | "onStageAll" + | "onUnstageAll" | "onStage" | "onUnstage" | "onPush" @@ -340,6 +344,9 @@ type TimelineProps = Pick< function TimelineLocalChanges(props: TimelineProps) { const showStaged = props.hasUnstaged || props.hasStaged; const showCommits = props.hasStaged || props.hasCommits; + // Only show bulk-action spinners when no individual files are pending + // (per-file ops set pendingStageFiles; bulk ops don't) + const isBulkOp = props.pendingStageFiles.size === 0; return ( <> @@ -350,6 +357,7 @@ function TimelineLocalChanges(props: TimelineProps) { pendingStageFiles={props.pendingStageFiles} isLast={!showStaged} actionLabel="Stage all" + isActionLoading={isBulkOp && props.loadingOperation === "stage"} onAction={props.onStageAll} onOpenDiff={props.onOpenDiffFile} onEditFile={props.onEditFile} @@ -365,7 +373,11 @@ function TimelineLocalChanges(props: TimelineProps) { pendingStageFiles={props.pendingStageFiles} isLast={!showCommits} actionLabel="Commit" + isActionLoading={props.loadingOperation === "commit"} onAction={props.dialogs.handleOpenCommitDialog} + secondaryActionLabel="Unstage all" + isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"} + onSecondaryAction={props.onUnstageAll} onOpenDiff={props.onOpenDiffFile} onEditFile={props.onEditFile} onStage={props.onStage} @@ -388,6 +400,7 @@ function TimelineLocalChanges(props: TimelineProps) { onOpenPRDialog={props.dialogs.handleOpenPRDialog} onPush={props.onPush} isLoading={props.isLoading} + loadingOperation={props.loadingOperation} aheadCount={props.aheadCount} canPush={props.canPush} canCreatePR={props.canCreatePR} @@ -511,13 +524,12 @@ const ChangesPanel = memo(function ChangesPanel({ const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]); const gitHandlers = useChangesGitHandlers(git, toast, baseBranch); - const dialogs = useChangesDialogHandlers( - git, - toast, - gitHandlers.handleGitOperation, + const firstCommitMessage = git.commits?.[0]?.commit_message; + const dialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation, { taskTitle, baseBranch, - ); + firstCommitMessage, + }); if (isArchived) return ; @@ -531,6 +543,7 @@ const ChangesPanel = memo(function ChangesPanel({ baseBranchDisplay={baseBranchDisplay} behindCount={git.behind} isLoading={git.isLoading} + loadingOperation={git.loadingOperation} onOpenDiffAll={onOpenDiffAll} onOpenReview={onOpenReview} onPull={gitHandlers.handlePull} @@ -556,6 +569,7 @@ const ChangesPanel = memo(function ChangesPanel({ totalFileCount={totalFileCount} aheadCount={git.ahead} isLoading={git.isLoading} + loadingOperation={git.loadingOperation} dialogs={dialogs} onOpenDiffFile={onOpenDiffFile} onEditFile={onEditFile} @@ -563,8 +577,9 @@ const ChangesPanel = memo(function ChangesPanel({ onRevertCommit={gitHandlers.handleRevertCommit} onOpenReview={onOpenReview} onStageAll={git.stageAll} - onStage={(path) => git.stage([path]).then(() => undefined)} - onUnstage={(path) => git.unstage([path]).then(() => undefined)} + onUnstageAll={git.unstageAll} + onStage={(path) => git.stageFile([path]).then(() => undefined)} + onUnstage={(path) => git.unstageFile([path]).then(() => undefined)} onPush={gitHandlers.handlePush} onForcePush={gitHandlers.handleForcePush} stagedFileCount={staged.stagedFileCount} diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 90f6004f10..454826415e 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useState } from "react"; -import { IconBrain } from "@tabler/icons-react"; +import { IconBrain, IconGitMerge, IconX } from "@tabler/icons-react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { SHORTCUTS } from "@/lib/keyboard/constants"; @@ -19,6 +19,8 @@ import { formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import { setChatDraftContent } from "@/lib/local-storage"; +import { useTaskActions } from "@/hooks/use-task-actions"; +import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; import type { useChatPanelState } from "./use-chat-panel-state"; @@ -246,6 +248,48 @@ After completing the implementation, provide a summary of what was done. }, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]); } +function PRMergedBanner({ taskId }: { taskId: string }) { + const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const [dismissed, setDismissed] = useState(false); + const { archiveTaskById } = useTaskActions(); + const { toast } = useToast(); + + const handleArchive = useCallback(async () => { + try { + await archiveTaskById(taskId); + toast({ description: "Task archived" }); + } catch { + toast({ description: "Failed to archive task", variant: "error" }); + } + }, [taskId, archiveTaskById, toast]); + + if (taskPR?.state !== "merged" || dismissed) return null; + + return ( +
+ + + PR #{taskPR.pr_number} has been merged. You can archive this task. + + + +
+ ); +} + type ChatInputAreaProps = { chatInputRef: React.RefObject; clarificationKey: number; @@ -321,6 +365,7 @@ export function ChatInputArea({ {agentMode} mode
)} + {taskId && } void; }) { const [prTitle, setPrTitle] = useState(""); @@ -207,7 +209,7 @@ export function PRDialog({ const handleOpen = (isOpen: boolean) => { if (isOpen) { - setPrTitle(taskTitle || ""); + setPrTitle(firstCommitMessage || taskTitle || ""); setPrBody(""); } onOpenChange(isOpen); diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx index 3e22e8947e..faea70bb75 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -249,6 +249,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({ baseBranch={baseBranch} isGitLoading={isGitLoading} taskTitle={taskTitle} + firstCommitMessage={commits[0]?.commit_message} onCreatePR={handleCreatePR} /> diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index ce3ea5a774..05018f759e 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -594,9 +594,10 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { return (
+ > +
+
); } diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 54b3047cfa..ce592d8916 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -308,7 +308,9 @@ export function ShellTerminal({ if (isReadOnlyMode) { return (
-
+
+
+
{isStopping && (
Stopping…
)} @@ -323,5 +325,9 @@ export function ShellTerminal({
); } - return
; + return ( +
+
+
+ ); } diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 09e8e2c54c..c276b18c2d 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -1,7 +1,8 @@ "use client"; import { memo, useState } from "react"; -import { IconLoader2 } from "@tabler/icons-react"; +import { IconLoader2, IconGitMerge } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { TaskItemMenu } from "./task-item-menu"; @@ -34,6 +35,7 @@ type TaskItemProps = { isDeleting?: boolean; taskId?: string; primarySessionId?: string | null; + prState?: "open" | "closed" | "merged" | null; }; // Helper to format relative time @@ -59,6 +61,37 @@ function handleTaskItemKeyDown(e: React.KeyboardEvent, onClick?: onClick?.(); } +function TaskItemTitle({ + title, + description, + prState, +}: { + title: string; + description?: string; + prState?: "open" | "closed" | "merged" | null; +}) { + return ( +
+ + {prState === "merged" && ( + + + + + + + PR merged + + )} + {title} + + {description && ( + {description} + )} +
+ ); +} + function TaskItemMeta({ effectiveMenuOpen, isRemoteExecutor, @@ -129,6 +162,7 @@ export const TaskItem = memo(function TaskItem({ isDeleting, taskId, primarySessionId, + prState, }: TaskItemProps) { const [menuOpen, setMenuOpen] = useState(false); @@ -162,14 +196,7 @@ export const TaskItem = memo(function TaskItem({ /> {/* Content */} -
- - {title} - - {description && ( - {description} - )} -
+ {/* Right side: step name + meta, or action buttons on hover */}
diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 6ff5546eab..66c4e5b670 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -99,6 +99,7 @@ function useSidebarData(workspaceId: string | null) { const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId); const archivedState = useArchivedTaskState(); const selectedTaskId = useMemo(() => { @@ -148,6 +149,7 @@ function useSidebarData(workspaceId: string | null) { primarySessionId: task.primarySessionId ?? null, updatedAt: sessionInfo.updatedAt ?? task.updatedAt, isArchived: false as boolean, + prState: (taskPRsByTaskId[task.id]?.state as "open" | "closed" | "merged") ?? null, }; }); if ( @@ -170,6 +172,9 @@ function useSidebarData(workspaceId: string | null) { primarySessionId: null, updatedAt: archivedState.archivedTaskUpdatedAt, isArchived: true, + prState: + (taskPRsByTaskId[archivedState.archivedTaskId]?.state as "open" | "closed" | "merged") ?? + null, }); } return items; @@ -179,6 +184,7 @@ function useSidebarData(workspaceId: string | null) { workspaceId, sessionsByTaskId, gitStatusBySessionId, + taskPRsByTaskId, archivedState, ]); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 1e3972f42e..800affca5c 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -36,6 +36,7 @@ type TaskSwitcherItem = { updatedAt?: string; isArchived?: boolean; primarySessionId?: string | null; + prState?: "open" | "closed" | "merged" | null; }; type TaskSwitcherProps = { @@ -149,6 +150,7 @@ function TaskSwitcherSection({ taskId={task.id} primarySessionId={task.primarySessionId ?? null} updatedAt={task.updatedAt} + prState={task.prState} onClick={() => onSelectTask(task.id)} onRename={onRenameTask ? () => onRenameTask(task.id, task.title) : undefined} onArchive={onArchiveTask ? () => onArchiveTask(task.id) : undefined} @@ -193,16 +195,6 @@ export const TaskSwitcher = memo(function TaskSwitcher({ else backlog.push(task); } - const byRecent = (a: TaskSwitcherItem, b: TaskSwitcherItem) => { - const ta = a.updatedAt ?? ""; - const tb = b.updatedAt ?? ""; - if (ta !== tb) return tb.localeCompare(ta); - return a.title.localeCompare(b.title); - }; - review.sort(byRecent); - inProgress.sort(byRecent); - backlog.sort(byRecent); - return [ { label: "Review", tasks: review }, { label: "In Progress", tasks: inProgress }, diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index 353e52809c..80293ebf70 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -131,7 +131,7 @@ export const backendFixture = base.extend({ KANDEV_DATA_DIR: dataDir, KANDEV_SERVER_PORT: String(backendPort), KANDEV_DATABASE_PATH: dbPath, - KANDEV_MOCK_AGENT: "true", + KANDEV_MOCK_AGENT: "only", KANDEV_MOCK_GITHUB: "true", KANDEV_DOCKER_ENABLED: "false", KANDEV_WORKTREE_ENABLED: "false", diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index 1265f1e518..b3cd5757ec 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -44,6 +44,7 @@ export type SessionGit = { // Operation state isLoading: boolean; + loadingOperation: string | null; pendingStageFiles: Set; // Actions @@ -54,8 +55,11 @@ export type SessionGit = { abort: (operation: "merge" | "rebase") => Promise; commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise; stage: (paths?: string[]) => Promise; + stageFile: (paths: string[]) => Promise; stageAll: () => Promise; unstage: (paths?: string[]) => Promise; + unstageFile: (paths: string[]) => Promise; + unstageAll: () => Promise; discard: (paths?: string[]) => Promise; revertCommit: (commitSHA: string) => Promise; renameBranch: (newName: string) => Promise; @@ -96,6 +100,41 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit const hasCommits = commits.length > 0; const stageAll = useCallback(async () => gitOps.stage(), [gitOps]); + const unstageAll = useCallback(async () => gitOps.unstage(), [gitOps]); + + const stageFile = useCallback( + async (paths: string[]) => { + for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); + try { + return await gitOps.stage(paths); + } catch (err) { + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const p of paths) next.delete(p); + return next; + }); + throw err; + } + }, + [gitOps], + ); + + const unstageFile = useCallback( + async (paths: string[]) => { + for (const p of paths) setPendingStageFiles((prev) => new Set(prev).add(p)); + try { + return await gitOps.unstage(paths); + } catch (err) { + setPendingStageFiles((prev) => { + const next = new Set(prev); + for (const p of paths) next.delete(p); + return next; + }); + throw err; + } + }, + [gitOps], + ); return { branch: gitStatus?.branch ?? null, @@ -122,6 +161,7 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit canCreatePR: hasCommits, isLoading: gitOps.isLoading, + loadingOperation: gitOps.loadingOperation, pendingStageFiles, pull: gitOps.pull, @@ -131,8 +171,11 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit abort: gitOps.abort, commit: gitOps.commit, stage: gitOps.stage, + stageFile, stageAll, unstage: gitOps.unstage, + unstageFile, + unstageAll, discard: gitOps.discard, revertCommit: gitOps.revertCommit, renameBranch: gitOps.renameBranch, diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index 51ea7292bf..40305d06cd 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -43,6 +43,7 @@ interface UseGitOperationsReturn { // State isLoading: boolean; + loadingOperation: string | null; error: string | null; lastResult: GitOperationResult | null; } @@ -128,6 +129,7 @@ function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { export function useGitOperations(sessionId: string | null): UseGitOperationsReturn { const [isLoading, setIsLoading] = useState(false); + const [loadingOperation, setLoadingOperation] = useState(null); const [error, setError] = useState(null); const [lastResult, setLastResult] = useState(null); @@ -141,6 +143,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu if (!client) throw new Error("WebSocket not connected"); setIsLoading(true); + setLoadingOperation(action.replace("worktree.", "")); setError(null); const timeout = action === "worktree.create_pr" ? 120000 : 60000; @@ -159,6 +162,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu throw e; } finally { setIsLoading(false); + setLoadingOperation(null); } }, [sessionId], @@ -169,6 +173,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu return { ...ops, isLoading, + loadingOperation, error, lastResult, }; diff --git a/apps/web/package.json b/apps/web/package.json index fac7f21df6..af80cc6763 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -99,6 +99,8 @@ "react-resizable-panels": "^4.4.1", "react-virtuoso": "^4.18.1", "recharts": "2.15.4", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.1", diff --git a/docs/test_e2e_web.md b/docs/test_e2e_web.md index 5fb8909d00..f81c6beb31 100644 --- a/docs/test_e2e_web.md +++ b/docs/test_e2e_web.md @@ -101,7 +101,7 @@ Each Playwright worker spawns an isolated `kandev` backend and a dedicated `next - `KANDEV_DATA_DIR` — temp data directory - `KANDEV_SERVER_PORT` — unique port per worker (`18080 + workerIndex`) - `KANDEV_DATABASE_PATH` — temp SQLite path - - `KANDEV_MOCK_AGENT=true` — only loads mock agent, skips agent discovery + - `KANDEV_MOCK_AGENT=only` — only loads mock agent, skips agent discovery (use `"true"` in dev mode to enable mock alongside all agents) - `KANDEV_MOCK_GITHUB=true` — uses in-memory MockClient instead of real GitHub API - `KANDEV_DOCKER_ENABLED=false` — no Docker - `KANDEV_WORKTREE_ENABLED=false` — no worktrees