fix: ui improvements for pr panel, git operations, and task sidebar (#359)

This commit is contained in:
Carlos Florêncio
2026-03-03 22:52:47 +00:00
committed by GitHub
parent 730751cece
commit 87a4438037
29 changed files with 573 additions and 94 deletions
+3 -2
View File
@@ -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
}
@@ -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
@@ -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")
}
}
})
}
}
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -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<string, string> = { KANDEV_DATABASE_PATH: dbPath };
if (process.env.KANDEV_MOCK_AGENT) {
extra.KANDEV_MOCK_AGENT = process.env.KANDEV_MOCK_AGENT;
}
const extra: Record<string, string> = {
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 =
+14
View File
@@ -8,6 +8,20 @@ import { Button } from "./button";
import { IconX } from "@tabler/icons-react";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
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 <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
+137 -13
View File
@@ -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: {}
+40 -6
View File
@@ -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
<div className="p-3 space-y-1">
{loading && !feedback && (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
<IconLoader2 className="h-6 w-6 text-blue-500 animate-spin" />
</div>
)}
{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 (
<div className="p-3 space-y-2">
@@ -398,7 +432,7 @@ function PRHeader({
<ApproveButton taskPR={taskPR} feedback={feedback} onRefresh={onRefresh} />
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<StateBadge state={taskPR.state} />
<StateBadge state={liveState} />
<span className="text-xs text-muted-foreground">#{taskPR.pr_number}</span>
<code className="text-[10px] px-1 py-0.5 bg-muted rounded font-mono">
{taskPR.head_branch}
@@ -418,7 +452,7 @@ function PRHeader({
Draft
</Badge>
)}
{!isMergeable && taskPR.state === "open" && (
{!isMergeable && liveState === "open" && (
<span className="flex items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400">
<IconAlertTriangle className="h-3 w-3" />
Not mergeable
+21 -3
View File
@@ -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 (
<div className="markdown-body max-w-none text-sm">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={markdownComponents}
>
{body}
</ReactMarkdown>
</div>
@@ -16,6 +16,14 @@ function prIconColor(state: string): string {
}
function PRStatusIcon({ pr }: { pr: TaskPR }) {
// Terminal states take priority
if (pr.state === "merged") {
return <IconCheck className="h-3 w-3 text-purple-500" />;
}
if (pr.state === "closed") {
return <IconX className="h-3 w-3 text-muted-foreground" />;
}
// Review/check states only matter for open PRs
if (pr.checks_state === "failure" || pr.review_state === "changes_requested") {
return <IconX className="h-3 w-3 text-red-500" />;
}
@@ -25,12 +33,6 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) {
if (pr.checks_state === "pending" || pr.review_state === "pending") {
return <IconClock className="h-3 w-3 text-yellow-500" />;
}
if (pr.state === "merged") {
return <IconCheck className="h-3 w-3 text-purple-500" />;
}
if (pr.state === "closed") {
return <IconX className="h-3 w-3 text-muted-foreground" />;
}
return null;
}
@@ -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}
@@ -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);
}
},
[
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -77,10 +83,18 @@ function PullDropdown({
className="h-5 text-[11px] px-1.5 gap-1 cursor-pointer"
disabled={isLoading}
>
<IconCloudDownload className="h-3 w-3" />
Pull
{behindCount > 0 && <span className="text-yellow-500 text-[10px]">{behindCount}</span>}
<IconChevronDown className="h-2.5 w-2.5 text-muted-foreground" />
{isPullRelated ? (
<IconLoader2 className="h-3 w-3 animate-spin" />
) : (
<IconCloudDownload className="h-3 w-3" />
)}
{isPulling && "Pulling…"}
{!isPulling && isRebasing && "Rebasing…"}
{!isPulling && !isRebasing && "Pull"}
{behindCount > 0 && !isPullRelated && (
<span className="text-yellow-500 text-[10px]">{behindCount}</span>
)}
{!isPullRelated && <IconChevronDown className="h-2.5 w-2.5 text-muted-foreground" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
@@ -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({
<PullDropdown
behindCount={behindCount}
isLoading={isLoading}
loadingOperation={loadingOperation}
onPull={onPull}
onRebase={onRebase}
/>
@@ -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 };
}
@@ -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}
>
<IconGitPullRequest className="h-3 w-3" />
{isCreatingPR ? (
<IconLoader2 className="h-3 w-3 animate-spin" />
) : (
<IconGitPullRequest className="h-3 w-3" />
)}
Create PR
</Button>
</span>
@@ -193,9 +202,13 @@ export function ActionButtonsSection({
onClick={onPush}
disabled={pushDisabled}
>
<IconCloudUpload className="h-3 w-3" />
{isPushing ? (
<IconLoader2 className="h-3 w-3 animate-spin" />
) : (
<IconCloudUpload className="h-3 w-3" />
)}
Push
{aheadCount > 0 && (
{aheadCount > 0 && !isPushing && (
<span className="text-muted-foreground">{aheadCount} ahead</span>
)}
</Button>
@@ -234,7 +247,11 @@ type FileListSectionProps = {
pendingStageFiles: Set<string>;
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({
</ul>
)}
{files.length > 0 && (
<div className="mt-1.5">
<div className="mt-1.5 flex items-center gap-1.5">
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onAction}
disabled={isActionLoading}
>
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{actionLabel}
</Button>
{onSecondaryAction && secondaryActionLabel && (
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onSecondaryAction}
disabled={isSecondaryActionLoading}
>
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{secondaryActionLabel}
</Button>
)}
</div>
)}
</TimelineSection>
+22 -7
View File
@@ -214,6 +214,7 @@ type ChangesPanelBodyProps = {
totalFileCount: number;
aheadCount: number;
isLoading: boolean;
loadingOperation: string | null;
dialogs: ReturnType<typeof useChangesDialogHandlers>;
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<void>;
onUnstage: (path: string) => Promise<void>;
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 <ArchivedPanelPlaceholder />;
@@ -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}
@@ -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 (
<div className="flex items-center gap-2 mx-1 mb-1 px-3 py-1.5 text-xs rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
<IconGitMerge className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">
PR #{taskPR.pr_number} has been merged. You can archive this task.
</span>
<button
type="button"
onClick={handleArchive}
className="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer"
>
Archive
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => setDismissed(true)}
className="p-0.5 hover:bg-purple-500/10 rounded cursor-pointer"
>
<IconX className="h-3 w-3" />
</button>
</div>
);
}
type ChatInputAreaProps = {
chatInputRef: React.RefObject<ChatInputContainerHandle | null>;
clarificationKey: number;
@@ -321,6 +365,7 @@ export function ChatInputArea({
<span className="capitalize">{agentMode} mode</span>
</div>
)}
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
<ChatInputContainer
ref={chatInputRef}
key={clarificationKey}
@@ -191,6 +191,7 @@ export function PRDialog({
baseBranch,
isGitLoading,
taskTitle,
firstCommitMessage,
onCreatePR,
}: {
open: boolean;
@@ -199,6 +200,7 @@ export function PRDialog({
baseBranch: string | undefined;
isGitLoading: boolean;
taskTitle: string | undefined;
firstCommitMessage?: string;
onCreatePR: (title: string, body: string, draft: boolean) => 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);
@@ -249,6 +249,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar({
baseBranch={baseBranch}
isGitLoading={isGitLoading}
taskTitle={taskTitle}
firstCommitMessage={commits[0]?.commit_message}
onCreatePR={handleCreatePR}
/>
</header>
@@ -594,9 +594,10 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
return (
<div
ref={terminalRef}
className="h-full w-full overflow-hidden p-2 bg-background"
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
/>
>
<div ref={terminalRef} className="h-full w-full" />
</div>
);
}
+8 -2
View File
@@ -308,7 +308,9 @@ export function ShellTerminal({
if (isReadOnlyMode) {
return (
<div className="h-full w-full bg-transparent relative">
<div ref={terminalRef} className="p-1 absolute inset-0" />
<div className="p-1 absolute inset-0">
<div ref={terminalRef} className="h-full w-full" />
</div>
{isStopping && (
<div className="absolute right-3 top-2 text-xs text-muted-foreground">Stopping…</div>
)}
@@ -323,5 +325,9 @@ export function ShellTerminal({
</div>
);
}
return <div ref={terminalRef} className="h-full p-1 w-full overflow-hidden bg-transparent" />;
return (
<div className="h-full p-1 w-full overflow-hidden bg-transparent">
<div ref={terminalRef} className="h-full w-full" />
</div>
);
}
+36 -9
View File
@@ -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<HTMLDivElement>, onClick?:
onClick?.();
}
function TaskItemTitle({
title,
description,
prState,
}: {
title: string;
description?: string;
prState?: "open" | "closed" | "merged" | null;
}) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1 line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
{prState === "merged" && (
<Tooltip>
<TooltipTrigger asChild>
<span aria-label="PR merged" className="inline-flex shrink-0">
<IconGitMerge className="h-3.5 w-3.5 text-purple-500" />
</span>
</TooltipTrigger>
<TooltipContent>PR merged</TooltipContent>
</Tooltip>
)}
{title}
</span>
{description && (
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
)}
</div>
);
}
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 */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
{title}
</span>
{description && (
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
)}
</div>
<TaskItemTitle title={title} description={description} prState={prState} />
{/* Right side: step name + meta, or action buttons on hover */}
<div className="relative flex items-center shrink-0">
@@ -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,
]);
+2 -10
View File
@@ -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 },
+1 -1
View File
@@ -131,7 +131,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
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",
@@ -44,6 +44,7 @@ export type SessionGit = {
// Operation state
isLoading: boolean;
loadingOperation: string | null;
pendingStageFiles: Set<string>;
// Actions
@@ -54,8 +55,11 @@ export type SessionGit = {
abort: (operation: "merge" | "rebase") => Promise<GitOperationResult>;
commit: (message: string, stageAll?: boolean, amend?: boolean) => Promise<GitOperationResult>;
stage: (paths?: string[]) => Promise<GitOperationResult>;
stageFile: (paths: string[]) => Promise<GitOperationResult>;
stageAll: () => Promise<GitOperationResult>;
unstage: (paths?: string[]) => Promise<GitOperationResult>;
unstageFile: (paths: string[]) => Promise<GitOperationResult>;
unstageAll: () => Promise<GitOperationResult>;
discard: (paths?: string[]) => Promise<GitOperationResult>;
revertCommit: (commitSHA: string) => Promise<GitOperationResult>;
renameBranch: (newName: string) => Promise<GitOperationResult>;
@@ -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,
+5
View File
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lastResult, setLastResult] = useState<GitOperationResult | null>(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,
};
+2
View File
@@ -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",
+1 -1
View File
@@ -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